From c6826a61a0a947acf09d65ada568c9c4e4494cb2 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 22 Feb 2024 10:07:36 -0700 Subject: [PATCH] talkers (#8158) Release Notes: - Added an "Unmute" action for guests in calls. This lets them use the mic, but not edit projects. --- crates/call/src/room.rs | 23 ++++++-- crates/channel/src/channel_store.rs | 3 +- crates/collab/src/db/ids.rs | 29 ++++++---- crates/collab/src/db/queries/channels.rs | 13 +++-- crates/collab/src/db/queries/projects.rs | 2 +- crates/collab/src/db/queries/rooms.rs | 2 +- crates/collab/src/rpc.rs | 53 +++++++++++------ crates/collab/src/rpc/connection_pool.rs | 52 ++++++++++++++++- .../collab/src/tests/channel_guest_tests.rs | 30 ++++++++-- crates/collab/src/tests/test_server.rs | 5 +- crates/collab_ui/src/collab_panel.rs | 57 +++++++++++++++---- crates/collab_ui/src/collab_titlebar_item.rs | 11 ++-- crates/rpc/proto/zed.proto | 1 + crates/util/src/semantic_version.rs | 11 +++- 14 files changed, 225 insertions(+), 67 deletions(-) diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index cd8af385ed..73577ffd1e 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -156,7 +156,7 @@ impl Room { cx.spawn(|this, mut cx| async move { connect.await?; this.update(&mut cx, |this, cx| { - if !this.read_only() { + if this.can_use_microphone() { if let Some(live_kit) = &this.live_kit { if !live_kit.muted_by_user && !live_kit.deafened { return this.share_microphone(cx); @@ -1322,11 +1322,6 @@ impl Room { }) } - pub fn read_only(&self) -> bool { - !(self.local_participant().role == proto::ChannelRole::Member - || self.local_participant().role == proto::ChannelRole::Admin) - } - pub fn is_speaking(&self) -> bool { self.live_kit .as_ref() @@ -1337,6 +1332,22 @@ impl Room { self.live_kit.as_ref().map(|live_kit| live_kit.deafened) } + pub fn can_use_microphone(&self) -> bool { + use proto::ChannelRole::*; + match self.local_participant.role { + Admin | Member | Talker => true, + Guest | Banned => false, + } + } + + pub fn can_share_projects(&self) -> bool { + use proto::ChannelRole::*; + match self.local_participant.role { + Admin | Member => true, + Guest | Banned | Talker => false, + } + } + #[track_caller] pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { if self.status.is_offline() { diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index abb5b560f0..b2d060334b 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -120,7 +120,8 @@ impl ChannelMembership { proto::ChannelRole::Admin => 0, proto::ChannelRole::Member => 1, proto::ChannelRole::Banned => 2, - proto::ChannelRole::Guest => 3, + proto::ChannelRole::Talker => 3, + proto::ChannelRole::Guest => 4, }, kind_order: match self.kind { proto::channel_member::Kind::Member => 0, diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 44a5db6a75..093fc6d885 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -100,8 +100,12 @@ pub enum ChannelRole { #[sea_orm(string_value = "member")] #[default] Member, + /// Talker can read, but not write. + /// They can use microphones and the channel chat + #[sea_orm(string_value = "talker")] + Talker, /// Guest can read, but not write. - /// (thought they can use the channel chat) + /// They can not use microphones but can use the chat. #[sea_orm(string_value = "guest")] Guest, /// Banned may not read. @@ -114,8 +118,9 @@ impl ChannelRole { pub fn should_override(&self, other: Self) -> bool { use ChannelRole::*; match self { - Admin => matches!(other, Member | Banned | Guest), - Member => matches!(other, Banned | Guest), + Admin => matches!(other, Member | Banned | Talker | Guest), + Member => matches!(other, Banned | Talker | Guest), + Talker => matches!(other, Guest), Banned => matches!(other, Guest), Guest => false, } @@ -134,7 +139,7 @@ impl ChannelRole { use ChannelRole::*; match self { Admin | Member => true, - Guest => visibility == ChannelVisibility::Public, + Guest | Talker => visibility == ChannelVisibility::Public, Banned => false, } } @@ -144,7 +149,7 @@ impl ChannelRole { use ChannelRole::*; match self { Admin | Member => true, - Guest | Banned => false, + Guest | Talker | Banned => false, } } @@ -152,16 +157,16 @@ impl ChannelRole { pub fn can_only_see_public_descendants(&self) -> bool { use ChannelRole::*; match self { - Guest => true, + Guest | Talker => true, Admin | Member | Banned => false, } } /// True if the role can share screen/microphone/projects into rooms. - pub fn can_publish_to_rooms(&self) -> bool { + pub fn can_use_microphone(&self) -> bool { use ChannelRole::*; match self { - Admin | Member => true, + Admin | Member | Talker => true, Guest | Banned => false, } } @@ -171,7 +176,7 @@ impl ChannelRole { use ChannelRole::*; match self { Admin | Member => true, - Guest | Banned => false, + Talker | Guest | Banned => false, } } @@ -179,7 +184,7 @@ impl ChannelRole { pub fn can_read_projects(&self) -> bool { use ChannelRole::*; match self { - Admin | Member | Guest => true, + Admin | Member | Guest | Talker => true, Banned => false, } } @@ -188,7 +193,7 @@ impl ChannelRole { use ChannelRole::*; match self { Admin | Member => true, - Banned | Guest => false, + Banned | Guest | Talker => false, } } } @@ -198,6 +203,7 @@ impl From for ChannelRole { match value { proto::ChannelRole::Admin => ChannelRole::Admin, proto::ChannelRole::Member => ChannelRole::Member, + proto::ChannelRole::Talker => ChannelRole::Talker, proto::ChannelRole::Guest => ChannelRole::Guest, proto::ChannelRole::Banned => ChannelRole::Banned, } @@ -209,6 +215,7 @@ impl Into for ChannelRole { match self { ChannelRole::Admin => proto::ChannelRole::Admin, ChannelRole::Member => proto::ChannelRole::Member, + ChannelRole::Talker => proto::ChannelRole::Talker, ChannelRole::Guest => proto::ChannelRole::Guest, ChannelRole::Banned => proto::ChannelRole::Banned, } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 1023b1b7c3..f46c5a75ff 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -795,6 +795,7 @@ impl Database { match role { Some(ChannelRole::Admin) => Ok(role.unwrap()), Some(ChannelRole::Member) + | Some(ChannelRole::Talker) | Some(ChannelRole::Banned) | Some(ChannelRole::Guest) | None => Err(anyhow!( @@ -813,7 +814,10 @@ impl Database { let channel_role = self.channel_role_for_user(channel, user_id, tx).await?; match channel_role { Some(ChannelRole::Admin) | Some(ChannelRole::Member) => Ok(channel_role.unwrap()), - Some(ChannelRole::Banned) | Some(ChannelRole::Guest) | None => Err(anyhow!( + Some(ChannelRole::Banned) + | Some(ChannelRole::Guest) + | Some(ChannelRole::Talker) + | None => Err(anyhow!( "user is not a channel member or channel does not exist" ))?, } @@ -828,9 +832,10 @@ impl Database { ) -> Result { let role = self.channel_role_for_user(channel, user_id, tx).await?; match role { - Some(ChannelRole::Admin) | Some(ChannelRole::Member) | Some(ChannelRole::Guest) => { - Ok(role.unwrap()) - } + Some(ChannelRole::Admin) + | Some(ChannelRole::Member) + | Some(ChannelRole::Guest) + | Some(ChannelRole::Talker) => Ok(role.unwrap()), Some(ChannelRole::Banned) | None => Err(anyhow!( "user is not a channel participant or channel does not exist" ))?, diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 3fdb94b343..33302eece1 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -51,7 +51,7 @@ impl Database { if !participant .role .unwrap_or(ChannelRole::Member) - .can_publish_to_rooms() + .can_edit_projects() { return Err(anyhow!("guests cannot share projects"))?; } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 275e75ff11..7bd2d87c80 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -169,7 +169,7 @@ impl Database { let called_user_role = match caller.role.unwrap_or(ChannelRole::Member) { ChannelRole::Admin | ChannelRole::Member => ChannelRole::Member, - ChannelRole::Guest => ChannelRole::Guest, + ChannelRole::Guest | ChannelRole::Talker => ChannelRole::Guest, ChannelRole::Banned => return Err(anyhow!("banned users cannot invite").into()), }; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7de28a34d3..82ed654505 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -28,7 +28,7 @@ use axum::{ Extension, Router, TypedHeader, }; use collections::{HashMap, HashSet}; -pub use connection_pool::ConnectionPool; +pub use connection_pool::{ConnectionPool, ZedVersion}; use futures::{ channel::oneshot, future::{self, BoxFuture}, @@ -558,6 +558,7 @@ impl Server { connection: Connection, address: String, user: User, + zed_version: ZedVersion, impersonator: Option, mut send_connection_id: Option>, executor: Executor, @@ -599,7 +600,7 @@ impl Server { { let mut pool = this.connection_pool.lock(); - pool.add_connection(connection_id, user_id, user.admin); + pool.add_connection(connection_id, user_id, user.admin, zed_version); this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_update_user_channels(&channels_for_user))?; this.peer.send(connection_id, build_channels_update( @@ -879,17 +880,20 @@ pub async fn handle_websocket_request( .into_response(); } - // the first version of zed that sent this header was 0.121.x - if let Some(version) = app_version_header.map(|header| header.0 .0) { - // 0.123.0 was a nightly version with incompatible collab changes - // that were reverted. - if version == "0.123.0".parse().unwrap() { - return ( - StatusCode::UPGRADE_REQUIRED, - "client must be upgraded".to_string(), - ) - .into_response(); - } + let Some(version) = app_version_header.map(|header| ZedVersion(header.0 .0)) else { + return ( + StatusCode::UPGRADE_REQUIRED, + "no version header found".to_string(), + ) + .into_response(); + }; + + if !version.is_supported() { + return ( + StatusCode::UPGRADE_REQUIRED, + "client must be upgraded".to_string(), + ) + .into_response(); } let socket_address = socket_address.to_string(); @@ -906,6 +910,7 @@ pub async fn handle_websocket_request( connection, socket_address, user, + version, impersonator.0, None, Executor::Production, @@ -1311,6 +1316,22 @@ async fn set_room_participant_role( response: Response, session: Session, ) -> Result<()> { + let user_id = UserId::from_proto(request.user_id); + let role = ChannelRole::from(request.role()); + + if role == ChannelRole::Talker { + let pool = session.connection_pool().await; + + for connection in pool.user_connections(user_id) { + if !connection.zed_version.supports_talker_role() { + Err(anyhow!( + "This user is on zed {} which does not support unmute", + connection.zed_version + ))?; + } + } + } + let (live_kit_room, can_publish) = { let room = session .db() @@ -1318,13 +1339,13 @@ async fn set_room_participant_role( .set_room_participant_role( session.user_id, RoomId::from_proto(request.room_id), - UserId::from_proto(request.user_id), - ChannelRole::from(request.role()), + user_id, + role, ) .await?; let live_kit_room = room.live_kit_room.clone(); - let can_publish = ChannelRole::from(request.role()).can_publish_to_rooms(); + let can_publish = ChannelRole::from(request.role()).can_use_microphone(); room_updated(&room, &session.peer); (live_kit_room, can_publish) }; diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index 30c4e144ed..e438fa2caf 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -4,6 +4,7 @@ use collections::{BTreeMap, HashSet}; use rpc::ConnectionId; use serde::Serialize; use tracing::instrument; +use util::SemanticVersion; #[derive(Default, Serialize)] pub struct ConnectionPool { @@ -16,10 +17,30 @@ struct ConnectedUser { connection_ids: HashSet, } +#[derive(Debug, Serialize)] +pub struct ZedVersion(pub SemanticVersion); +use std::fmt; + +impl fmt::Display for ZedVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ZedVersion { + pub fn is_supported(&self) -> bool { + self.0 != SemanticVersion::new(0, 123, 0) + } + pub fn supports_talker_role(&self) -> bool { + self.0 >= SemanticVersion::new(0, 125, 0) + } +} + #[derive(Serialize)] pub struct Connection { pub user_id: UserId, pub admin: bool, + pub zed_version: ZedVersion, } impl ConnectionPool { @@ -29,9 +50,21 @@ impl ConnectionPool { } #[instrument(skip(self))] - pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) { - self.connections - .insert(connection_id, Connection { user_id, admin }); + pub fn add_connection( + &mut self, + connection_id: ConnectionId, + user_id: UserId, + admin: bool, + zed_version: ZedVersion, + ) { + self.connections.insert( + connection_id, + Connection { + user_id, + admin, + zed_version, + }, + ); let connected_user = self.connected_users.entry(user_id).or_default(); connected_user.connection_ids.insert(connection_id); } @@ -57,6 +90,19 @@ impl ConnectionPool { self.connections.values() } + pub fn user_connections(&self, user_id: UserId) -> impl Iterator + '_ { + self.connected_users + .get(&user_id) + .into_iter() + .map(|state| { + state + .connection_ids + .iter() + .flat_map(|cid| self.connections.get(cid)) + }) + .flatten() + } + pub fn user_connection_ids(&self, user_id: UserId) -> impl Iterator + '_ { self.connected_users .get(&user_id) diff --git a/crates/collab/src/tests/channel_guest_tests.rs b/crates/collab/src/tests/channel_guest_tests.rs index bb1f493f0c..9599aa788c 100644 --- a/crates/collab/src/tests/channel_guest_tests.rs +++ b/crates/collab/src/tests/channel_guest_tests.rs @@ -104,7 +104,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test }); assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx))); - assert!(room_b.read_with(cx_b, |room, _| room.read_only())); + assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); assert!(room_b .update(cx_b, |room, cx| room.share_microphone(cx)) .await @@ -130,7 +130,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx))); // B sees themselves as muted, and can unmute. - assert!(room_b.read_with(cx_b, |room, _| !room.read_only())); + assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); room_b.read_with(cx_b, |room, _| assert!(room.is_muted())); room_b.update(cx_b, |room, cx| room.toggle_mute(cx)); cx_a.run_until_parked(); @@ -223,7 +223,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes let room_b = cx_b .read(ActiveCall::global) .update(cx_b, |call, _| call.room().unwrap().clone()); - assert!(room_b.read_with(cx_b, |room, _| room.read_only())); + assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); // A tries to grant write access to B, but cannot because B has not // yet signed the zed CLA. @@ -240,7 +240,26 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .await .unwrap_err(); cx_a.run_until_parked(); - assert!(room_b.read_with(cx_b, |room, _| room.read_only())); + assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); + assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + + // A tries to grant write access to B, but cannot because B has not + // yet signed the zed CLA. + active_call_a + .update(cx_a, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_participant_role( + client_b.user_id().unwrap(), + proto::ChannelRole::Talker, + cx, + ) + }) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); + assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); // User B signs the zed CLA. server @@ -264,5 +283,6 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .await .unwrap(); cx_a.run_until_parked(); - assert!(room_b.read_with(cx_b, |room, _| !room.read_only())); + assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects())); + assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); } diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 6fcae4a05d..c12631761d 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -1,7 +1,7 @@ use crate::{ db::{tests::TestDb, NewUserParams, UserId}, executor::Executor, - rpc::{Server, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, + rpc::{Server, ZedVersion, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, AppState, Config, }; use anyhow::anyhow; @@ -37,7 +37,7 @@ use std::{ Arc, }, }; -use util::http::FakeHttpClient; +use util::{http::FakeHttpClient, SemanticVersion}; use workspace::{Workspace, WorkspaceStore}; pub struct TestServer { @@ -231,6 +231,7 @@ impl TestServer { server_conn, client_name, user, + ZedVersion(SemanticVersion::new(1, 0, 0)), None, Some(connection_id_tx), Executor::Deterministic(cx.background_executor().clone()), diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index d84fa78d4f..911b5b002e 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -854,6 +854,10 @@ impl CollabPanel { .into_any_element() } else if role == proto::ChannelRole::Guest { Label::new("Guest").color(Color::Muted).into_any_element() + } else if role == proto::ChannelRole::Talker { + Label::new("Mic only") + .color(Color::Muted) + .into_any_element() } else { div().into_any_element() }) @@ -1013,13 +1017,38 @@ impl CollabPanel { cx: &mut ViewContext, ) { let this = cx.view().clone(); - if !(role == proto::ChannelRole::Guest || role == proto::ChannelRole::Member) { + if !(role == proto::ChannelRole::Guest + || role == proto::ChannelRole::Talker + || role == proto::ChannelRole::Member) + { return; } - let context_menu = ContextMenu::build(cx, |context_menu, cx| { + let context_menu = ContextMenu::build(cx, |mut context_menu, cx| { if role == proto::ChannelRole::Guest { - context_menu.entry( + context_menu = context_menu.entry( + "Grant Mic Access", + None, + cx.handler_for(&this, move |_, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| { + let Some(room) = call.room() else { + return Task::ready(Ok(())); + }; + room.update(cx, |room, cx| { + room.set_participant_role( + user_id, + proto::ChannelRole::Talker, + cx, + ) + }) + }) + .detach_and_prompt_err("Failed to grant mic access", cx, |_, _| None) + }), + ); + } + if role == proto::ChannelRole::Guest || role == proto::ChannelRole::Talker { + context_menu = context_menu.entry( "Grant Write Access", None, cx.handler_for(&this, move |_, cx| { @@ -1043,10 +1072,16 @@ impl CollabPanel { } }) }), - ) - } else if role == proto::ChannelRole::Member { - context_menu.entry( - "Revoke Write Access", + ); + } + if role == proto::ChannelRole::Member || role == proto::ChannelRole::Talker { + let label = if role == proto::ChannelRole::Talker { + "Mute" + } else { + "Revoke Access" + }; + context_menu = context_menu.entry( + label, None, cx.handler_for(&this, move |_, cx| { ActiveCall::global(cx) @@ -1062,12 +1097,12 @@ impl CollabPanel { ) }) }) - .detach_and_prompt_err("Failed to revoke write access", cx, |_, _| None) + .detach_and_prompt_err("Failed to revoke access", cx, |_, _| None) }), - ) - } else { - unreachable!() + ); } + + context_menu }); cx.focus_view(&context_menu); diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index c759ce3098..854809994b 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -187,9 +187,10 @@ impl Render for CollabTitlebarItem { let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); - let read_only = room.read_only(); + let can_use_microphone = room.can_use_microphone(); + let can_share_projects = room.can_share_projects(); - this.when(is_local && !read_only, |this| { + this.when(is_local && can_share_projects, |this| { this.child( Button::new( "toggle_sharing", @@ -235,7 +236,7 @@ impl Render for CollabTitlebarItem { ) .pr_2(), ) - .when(!read_only, |this| { + .when(can_use_microphone, |this| { this.child( IconButton::new( "mute-microphone", @@ -276,7 +277,7 @@ impl Render for CollabTitlebarItem { .icon_size(IconSize::Small) .selected(is_deafened) .tooltip(move |cx| { - if !read_only { + if can_use_microphone { Tooltip::with_meta( "Deafen Audio", None, @@ -289,7 +290,7 @@ impl Render for CollabTitlebarItem { }) .on_click(move |_, cx| crate::toggle_deafen(&Default::default(), cx)), ) - .when(!read_only, |this| { + .when(can_share_projects, |this| { this.child( IconButton::new("screen-share", ui::IconName::Screen) .style(ButtonStyle::Subtle) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 699c7e9a1e..2ad8bd4448 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1107,6 +1107,7 @@ enum ChannelRole { Member = 1; Guest = 2; Banned = 3; + Talker = 4; } message SetChannelMemberRole { diff --git a/crates/util/src/semantic_version.rs b/crates/util/src/semantic_version.rs index f5e4562adf..851bedc7aa 100644 --- a/crates/util/src/semantic_version.rs +++ b/crates/util/src/semantic_version.rs @@ -14,9 +14,18 @@ pub struct SemanticVersion { pub patch: usize, } +impl SemanticVersion { + pub fn new(major: usize, minor: usize, patch: usize) -> Self { + Self { + major, + minor, + patch, + } + } +} + impl FromStr for SemanticVersion { type Err = anyhow::Error; - fn from_str(s: &str) -> Result { let mut components = s.trim().split('.'); let major = components