Add channel links (#3093)

Conrad Irwin created

Release notes:

- `mute_on_join` setting now defaults to false.
- Right click on a channel to "Copy Channel Link", these links work to
open Zed and auto-join the channel

Blocked on: https://github.com/zed-industries/zed.dev/pull/388

Change summary

assets/settings/default.json                                             |   2 
crates/call/src/room.rs                                                  |  31 
crates/channel/src/channel_store.rs                                      |  21 
crates/cli/src/main.rs                                                   |   1 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql           |   1 
crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql |   1 
crates/collab/src/db/queries/rooms.rs                                    |  32 
crates/collab/src/db/tables/room.rs                                      |   1 
crates/collab/src/db/tests.rs                                            |   2 
crates/collab/src/db/tests/channel_tests.rs                              |  20 
crates/collab/src/db/tests/db_tests.rs                                   |  92 
crates/collab/src/rpc.rs                                                 |  22 
crates/collab_ui/src/collab_panel.rs                                     |  83 
crates/gpui/src/platform/mac/platform.rs                                 |  24 
crates/util/src/channel.rs                                               |  32 
crates/workspace/src/workspace.rs                                        | 190 
crates/zed/Cargo.toml                                                    |   3 
crates/zed/src/main.rs                                                   | 211 
crates/zed/src/open_listener.rs                                          |  98 
script/bundle                                                            |  35 
20 files changed, 709 insertions(+), 193 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -76,7 +76,7 @@
   // Settings related to calls in Zed
   "calls": {
     // Join calls with the microphone muted by default
-    "mute_on_join": true
+    "mute_on_join": false
   },
   // Scrollbar related settings
   "scrollbar": {

crates/call/src/room.rs 🔗

@@ -18,7 +18,7 @@ use live_kit_client::{
     LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RemoteAudioTrackUpdate,
     RemoteVideoTrackUpdate,
 };
-use postage::stream::Stream;
+use postage::{sink::Sink, stream::Stream, watch};
 use project::Project;
 use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration};
 use util::{post_inc, ResultExt, TryFutureExt};
@@ -70,6 +70,8 @@ pub struct Room {
     user_store: ModelHandle<UserStore>,
     follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec<PeerId>>,
     subscriptions: Vec<client::Subscription>,
+    room_update_completed_tx: watch::Sender<Option<()>>,
+    room_update_completed_rx: watch::Receiver<Option<()>>,
     pending_room_update: Option<Task<()>>,
     maintain_connection: Option<Task<Option<()>>>,
 }
@@ -211,6 +213,8 @@ impl Room {
 
         Audio::play_sound(Sound::Joined, cx);
 
+        let (room_update_completed_tx, room_update_completed_rx) = watch::channel();
+
         Self {
             id,
             channel_id,
@@ -230,6 +234,8 @@ impl Room {
             user_store,
             follows_by_leader_id_project_id: Default::default(),
             maintain_connection: Some(maintain_connection),
+            room_update_completed_tx,
+            room_update_completed_rx,
         }
     }
 
@@ -599,7 +605,7 @@ impl Room {
     }
 
     /// Returns the most 'active' projects, defined as most people in the project
-    pub fn most_active_project(&self) -> Option<(u64, u64)> {
+    pub fn most_active_project(&self, cx: &AppContext) -> Option<(u64, u64)> {
         let mut project_hosts_and_guest_counts = HashMap::<u64, (Option<u64>, u32)>::default();
         for participant in self.remote_participants.values() {
             match participant.location {
@@ -619,6 +625,15 @@ impl Room {
             }
         }
 
+        if let Some(user) = self.user_store.read(cx).current_user() {
+            for project in &self.local_participant.projects {
+                project_hosts_and_guest_counts
+                    .entry(project.id)
+                    .or_default()
+                    .0 = Some(user.id);
+            }
+        }
+
         project_hosts_and_guest_counts
             .into_iter()
             .filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count)))
@@ -858,6 +873,7 @@ impl Room {
                 });
 
                 this.check_invariants();
+                this.room_update_completed_tx.try_send(Some(())).ok();
                 cx.notify();
             });
         }));
@@ -866,6 +882,17 @@ impl Room {
         Ok(())
     }
 
+    pub fn room_update_completed(&mut self) -> impl Future<Output = ()> {
+        let mut done_rx = self.room_update_completed_rx.clone();
+        async move {
+            while let Some(result) = done_rx.next().await {
+                if result.is_some() {
+                    break;
+                }
+            }
+        }
+    }
+
     fn remote_video_track_updated(
         &mut self,
         change: RemoteVideoTrackUpdate,

crates/channel/src/channel_store.rs 🔗

@@ -5,6 +5,7 @@ use anyhow::{anyhow, Result};
 use channel_index::ChannelIndex;
 use client::{Client, Subscription, User, UserId, UserStore};
 use collections::{hash_map, HashMap, HashSet};
+use db::RELEASE_CHANNEL;
 use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
 use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
 use rpc::{
@@ -52,6 +53,26 @@ pub struct Channel {
     pub unseen_message_id: Option<u64>,
 }
 
+impl Channel {
+    pub fn link(&self) -> String {
+        RELEASE_CHANNEL.link_prefix().to_owned()
+            + "channel/"
+            + &self.slug()
+            + "-"
+            + &self.id.to_string()
+    }
+
+    pub fn slug(&self) -> String {
+        let slug: String = self
+            .name
+            .chars()
+            .map(|c| if c.is_alphanumeric() { c } else { '-' })
+            .collect();
+
+        slug.trim_matches(|c| c == '-').to_string()
+    }
+}
+
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
 pub struct ChannelPath(Arc<[ChannelId]>);
 

crates/cli/src/main.rs 🔗

@@ -182,6 +182,7 @@ impl Bundle {
                         kCFStringEncodingUTF8,
                         ptr::null(),
                     ));
+                    // equivalent to: open zed-cli:... -a /Applications/Zed\ Preview.app
                     let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
                     LSOpenFromURLSpec(
                         &LSLaunchURLSpec {

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -37,6 +37,7 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
 CREATE TABLE "rooms" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "live_kit_room" VARCHAR NOT NULL,
+    "release_channel" VARCHAR,
     "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
 );
 

crates/collab/src/db/queries/rooms.rs 🔗

@@ -107,10 +107,12 @@ impl Database {
         user_id: UserId,
         connection: ConnectionId,
         live_kit_room: &str,
+        release_channel: &str,
     ) -> Result<proto::Room> {
         self.transaction(|tx| async move {
             let room = room::ActiveModel {
                 live_kit_room: ActiveValue::set(live_kit_room.into()),
+                release_channel: ActiveValue::set(Some(release_channel.to_string())),
                 ..Default::default()
             }
             .insert(&*tx)
@@ -270,20 +272,31 @@ impl Database {
         room_id: RoomId,
         user_id: UserId,
         connection: ConnectionId,
+        collab_release_channel: &str,
     ) -> Result<RoomGuard<JoinRoom>> {
         self.room_transaction(room_id, |tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-            enum QueryChannelId {
+            enum QueryChannelIdAndReleaseChannel {
                 ChannelId,
+                ReleaseChannel,
+            }
+
+            let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
+                room::Entity::find()
+                    .select_only()
+                    .column(room::Column::ChannelId)
+                    .column(room::Column::ReleaseChannel)
+                    .filter(room::Column::Id.eq(room_id))
+                    .into_values::<_, QueryChannelIdAndReleaseChannel>()
+                    .one(&*tx)
+                    .await?
+                    .ok_or_else(|| anyhow!("no such room"))?;
+
+            if let Some(release_channel) = release_channel {
+                if &release_channel != collab_release_channel {
+                    Err(anyhow!("must join using the {} release", release_channel))?;
+                }
             }
-            let channel_id: Option<ChannelId> = room::Entity::find()
-                .select_only()
-                .column(room::Column::ChannelId)
-                .filter(room::Column::Id.eq(room_id))
-                .into_values::<_, QueryChannelId>()
-                .one(&*tx)
-                .await?
-                .ok_or_else(|| anyhow!("no such room"))?;
 
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
             enum QueryParticipantIndices {
@@ -300,6 +313,7 @@ impl Database {
                 .into_values::<_, QueryParticipantIndices>()
                 .all(&*tx)
                 .await?;
+
             let mut participant_index = 0;
             while existing_participant_indices.contains(&participant_index) {
                 participant_index += 1;

crates/collab/src/db/tables/room.rs 🔗

@@ -8,6 +8,7 @@ pub struct Model {
     pub id: RoomId,
     pub live_kit_room: String,
     pub channel_id: Option<ChannelId>,
+    pub release_channel: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/db/tests.rs 🔗

@@ -12,6 +12,8 @@ use sea_orm::ConnectionTrait;
 use sqlx::migrate::MigrateDatabase;
 use std::sync::Arc;
 
+const TEST_RELEASE_CHANNEL: &'static str = "test";
+
 pub struct TestDb {
     pub db: Option<Arc<Database>>,
     pub connection: Option<sqlx::AnyConnection>,

crates/collab/src/db/tests/channel_tests.rs 🔗

@@ -5,7 +5,11 @@ use rpc::{
 };
 
 use crate::{
-    db::{queries::channels::ChannelGraph, tests::graph, ChannelId, Database, NewUserParams},
+    db::{
+        queries::channels::ChannelGraph,
+        tests::{graph, TEST_RELEASE_CHANNEL},
+        ChannelId, Database, NewUserParams,
+    },
     test_both_dbs,
 };
 use std::sync::Arc;
@@ -206,7 +210,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
 
     // can join a room with membership to its channel
     let joined_room = db
-        .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 })
+        .join_room(
+            room_1,
+            user_1,
+            ConnectionId { owner_id, id: 1 },
+            TEST_RELEASE_CHANNEL,
+        )
         .await
         .unwrap();
     assert_eq!(joined_room.room.participants.len(), 1);
@@ -214,7 +223,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
     drop(joined_room);
     // cannot join a room without membership to its channel
     assert!(db
-        .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 })
+        .join_room(
+            room_1,
+            user_2,
+            ConnectionId { owner_id, id: 1 },
+            TEST_RELEASE_CHANNEL
+        )
         .await
         .is_err());
 }

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -479,7 +479,7 @@ async fn test_project_count(db: &Arc<Database>) {
         .unwrap();
 
     let room_id = RoomId::from_proto(
-        db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
+        db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "", "dev")
             .await
             .unwrap()
             .id,
@@ -493,9 +493,14 @@ async fn test_project_count(db: &Arc<Database>) {
     )
     .await
     .unwrap();
-    db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
-        .await
-        .unwrap();
+    db.join_room(
+        room_id,
+        user2.user_id,
+        ConnectionId { owner_id, id: 1 },
+        "dev",
+    )
+    .await
+    .unwrap();
     assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
 
     db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
@@ -575,6 +580,85 @@ async fn test_fuzzy_search_users() {
     }
 }
 
+test_both_dbs!(
+    test_non_matching_release_channels,
+    test_non_matching_release_channels_postgres,
+    test_non_matching_release_channels_sqlite
+);
+
+async fn test_non_matching_release_channels(db: &Arc<Database>) {
+    let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
+    let user1 = db
+        .create_user(
+            &format!("admin@example.com"),
+            true,
+            NewUserParams {
+                github_login: "admin".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    let user2 = db
+        .create_user(
+            &format!("user@example.com"),
+            false,
+            NewUserParams {
+                github_login: "user".into(),
+                github_user_id: 1,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+
+    let room = db
+        .create_room(
+            user1.user_id,
+            ConnectionId { owner_id, id: 0 },
+            "",
+            "stable",
+        )
+        .await
+        .unwrap();
+
+    db.call(
+        RoomId::from_proto(room.id),
+        user1.user_id,
+        ConnectionId { owner_id, id: 0 },
+        user2.user_id,
+        None,
+    )
+    .await
+    .unwrap();
+
+    // User attempts to join from preview
+    let result = db
+        .join_room(
+            RoomId::from_proto(room.id),
+            user2.user_id,
+            ConnectionId { owner_id, id: 1 },
+            "preview",
+        )
+        .await;
+
+    assert!(result.is_err());
+
+    // User switches to stable
+    let result = db
+        .join_room(
+            RoomId::from_proto(room.id),
+            user2.user_id,
+            ConnectionId { owner_id, id: 1 },
+            "stable",
+        )
+        .await;
+
+    assert!(result.is_ok())
+}
+
 fn build_background_executor() -> Arc<Background> {
     Deterministic::new(0).build_background()
 }

crates/collab/src/rpc.rs 🔗

@@ -63,6 +63,7 @@ use time::OffsetDateTime;
 use tokio::sync::{watch, Semaphore};
 use tower::ServiceBuilder;
 use tracing::{info_span, instrument, Instrument};
+use util::channel::RELEASE_CHANNEL_NAME;
 
 pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
 pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
@@ -957,7 +958,12 @@ async fn create_room(
     let room = session
         .db()
         .await
-        .create_room(session.user_id, session.connection_id, &live_kit_room)
+        .create_room(
+            session.user_id,
+            session.connection_id,
+            &live_kit_room,
+            RELEASE_CHANNEL_NAME.as_str(),
+        )
         .await?;
 
     response.send(proto::CreateRoomResponse {
@@ -979,7 +985,12 @@ async fn join_room(
         let room = session
             .db()
             .await
-            .join_room(room_id, session.user_id, session.connection_id)
+            .join_room(
+                room_id,
+                session.user_id,
+                session.connection_id,
+                RELEASE_CHANNEL_NAME.as_str(),
+            )
             .await?;
         room_updated(&room.room, &session.peer);
         room.into_inner()
@@ -2616,7 +2627,12 @@ async fn join_channel(
         let room_id = db.room_id_for_channel(channel_id).await?;
 
         let joined_room = db
-            .join_room(room_id, session.user_id, session.connection_id)
+            .join_room(
+                room_id,
+                session.user_id,
+                session.connection_id,
+                RELEASE_CHANNEL_NAME.as_str(),
+            )
             .await?;
 
         let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -34,8 +34,8 @@ use gpui::{
     },
     impl_actions,
     platform::{CursorStyle, MouseButton, PromptLevel},
-    serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, FontCache, ModelHandle,
-    Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    serde_json, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, FontCache,
+    ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::{Fs, Project};
@@ -100,6 +100,11 @@ pub struct JoinChannelChat {
     pub channel_id: u64,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct CopyChannelLink {
+    pub channel_id: u64,
+}
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 struct StartMoveChannelFor {
     channel_id: ChannelId,
@@ -157,6 +162,7 @@ impl_actions!(
         OpenChannelNotes,
         JoinChannelCall,
         JoinChannelChat,
+        CopyChannelLink,
         LinkChannel,
         StartMoveChannelFor,
         StartLinkChannelFor,
@@ -205,6 +211,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(CollabPanel::expand_selected_channel);
     cx.add_action(CollabPanel::open_channel_notes);
     cx.add_action(CollabPanel::join_channel_chat);
+    cx.add_action(CollabPanel::copy_channel_link);
 
     cx.add_action(
         |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext<CollabPanel>| {
@@ -2568,6 +2575,13 @@ impl CollabPanel {
                 },
             ));
 
+            items.push(ContextMenuItem::action(
+                "Copy Channel Link",
+                CopyChannelLink {
+                    channel_id: path.channel_id(),
+                },
+            ));
+
             if self.channel_store.read(cx).is_user_admin(path.channel_id()) {
                 let parent_id = path.parent_id();
 
@@ -3187,49 +3201,19 @@ impl CollabPanel {
     }
 
     fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
-        let workspace = self.workspace.clone();
-        let window = cx.window();
-        let active_call = ActiveCall::global(cx);
-        cx.spawn(|_, mut cx| async move {
-            if active_call.read_with(&mut cx, |active_call, cx| {
-                if let Some(room) = active_call.room() {
-                    let room = room.read(cx);
-                    room.is_sharing_project() && room.remote_participants().len() > 0
-                } else {
-                    false
-                }
-            }) {
-                let answer = window.prompt(
-                    PromptLevel::Warning,
-                    "Leaving this call will unshare your current project.\nDo you want to switch channels?",
-                    &["Yes, Join Channel", "Cancel"],
-                    &mut cx,
-                );
-
-                if let Some(mut answer) = answer {
-                    if answer.next().await == Some(1) {
-                        return anyhow::Ok(());
-                    }
-                }
-            }
-
-            let room = active_call
-                .update(&mut cx, |call, cx| call.join_channel(channel_id, cx))
-                .await?;
-
-            let task = room.update(&mut cx, |room, cx| {
-                let workspace = workspace.upgrade(cx)?;
-                let (project, host) = room.most_active_project()?;
-                let app_state = workspace.read(cx).app_state().clone();
-                Some(workspace::join_remote_project(project, host, app_state, cx))
-            });
-            if let Some(task) = task {
-                task.await?;
-            }
-
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
+        let Some(workspace) = self.workspace.upgrade(cx) else {
+            return;
+        };
+        let Some(handle) = cx.window().downcast::<Workspace>() else {
+            return;
+        };
+        workspace::join_channel(
+            channel_id,
+            workspace.read(cx).app_state().clone(),
+            Some(handle),
+            cx,
+        )
+        .detach_and_log_err(cx)
     }
 
     fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {
@@ -3246,6 +3230,15 @@ impl CollabPanel {
             });
         }
     }
+
+    fn copy_channel_link(&mut self, action: &CopyChannelLink, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        let Some(channel) = channel_store.channel_for_id(action.channel_id) else {
+            return;
+        };
+        let item = ClipboardItem::new(channel.link());
+        cx.write_to_clipboard(item)
+    }
 }
 
 fn render_tree_branch(

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -140,6 +140,10 @@ unsafe fn build_classes() {
             sel!(application:openURLs:),
             open_urls as extern "C" fn(&mut Object, Sel, id, id),
         );
+        decl.add_method(
+            sel!(application:continueUserActivity:restorationHandler:),
+            continue_user_activity as extern "C" fn(&mut Object, Sel, id, id, id),
+        );
         decl.register()
     }
 }
@@ -1009,6 +1013,26 @@ extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) {
     }
 }
 
+extern "C" fn continue_user_activity(this: &mut Object, _: Sel, _: id, user_activity: id, _: id) {
+    let url = unsafe {
+        let url: id = msg_send!(user_activity, webpageURL);
+        if url == nil {
+            log::error!("got unexpected user activity");
+            None
+        } else {
+            Some(
+                CStr::from_ptr(url.absoluteString().UTF8String())
+                    .to_string_lossy()
+                    .to_string(),
+            )
+        }
+    };
+    let platform = unsafe { get_foreground_platform(this) };
+    if let Some(callback) = platform.0.borrow_mut().open_urls.as_mut() {
+        callback(url.into_iter().collect());
+    }
+}
+
 extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
     unsafe {
         let platform = get_foreground_platform(this);

crates/util/src/channel.rs 🔗

@@ -41,4 +41,36 @@ impl ReleaseChannel {
             ReleaseChannel::Stable => "stable",
         }
     }
+
+    pub fn url_scheme(&self) -> &'static str {
+        match self {
+            ReleaseChannel::Dev => "zed-dev://",
+            ReleaseChannel::Preview => "zed-preview://",
+            ReleaseChannel::Stable => "zed://",
+        }
+    }
+
+    pub fn link_prefix(&self) -> &'static str {
+        match self {
+            ReleaseChannel::Dev => "https://zed.dev/dev/",
+            ReleaseChannel::Preview => "https://zed.dev/preview/",
+            ReleaseChannel::Stable => "https://zed.dev/",
+        }
+    }
+}
+
+pub fn parse_zed_link(link: &str) -> Option<&str> {
+    for release in [
+        ReleaseChannel::Dev,
+        ReleaseChannel::Preview,
+        ReleaseChannel::Stable,
+    ] {
+        if let Some(stripped) = link.strip_prefix(release.link_prefix()) {
+            return Some(stripped);
+        }
+        if let Some(stripped) = link.strip_prefix(release.url_scheme()) {
+            return Some(stripped);
+        }
+    }
+    None
 }

crates/workspace/src/workspace.rs 🔗

@@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result};
 use call::ActiveCall;
 use client::{
     proto::{self, PeerId},
-    Client, TypedEnvelope, UserStore,
+    Client, Status, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use drag_and_drop::DragAndDrop;
@@ -35,9 +35,9 @@ use gpui::{
         CursorStyle, ModifiersChangedEvent, MouseButton, PathPromptOptions, Platform, PromptLevel,
         WindowBounds, WindowOptions,
     },
-    AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity,
-    ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
-    WeakViewHandle, WindowContext, WindowHandle,
+    AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AnyWindowHandle, AppContext, AsyncAppContext,
+    Entity, ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext,
+    ViewHandle, WeakViewHandle, WindowContext, WindowHandle,
 };
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
 use itertools::Itertools;
@@ -4139,6 +4139,188 @@ pub async fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
     DB.last_workspace().await.log_err().flatten()
 }
 
+async fn join_channel_internal(
+    channel_id: u64,
+    app_state: &Arc<AppState>,
+    requesting_window: Option<WindowHandle<Workspace>>,
+    active_call: &ModelHandle<ActiveCall>,
+    cx: &mut AsyncAppContext,
+) -> Result<bool> {
+    let (should_prompt, open_room) = active_call.read_with(cx, |active_call, cx| {
+        let Some(room) = active_call.room().map(|room| room.read(cx)) else {
+            return (false, None);
+        };
+
+        let already_in_channel = room.channel_id() == Some(channel_id);
+        let should_prompt = room.is_sharing_project()
+            && room.remote_participants().len() > 0
+            && !already_in_channel;
+        let open_room = if already_in_channel {
+            active_call.room().cloned()
+        } else {
+            None
+        };
+        (should_prompt, open_room)
+    });
+
+    if let Some(room) = open_room {
+        let task = room.update(cx, |room, cx| {
+            if let Some((project, host)) = room.most_active_project(cx) {
+                return Some(join_remote_project(project, host, app_state.clone(), cx));
+            }
+
+            None
+        });
+        if let Some(task) = task {
+            task.await?;
+        }
+        return anyhow::Ok(true);
+    }
+
+    if should_prompt {
+        if let Some(workspace) = requesting_window {
+            if let Some(window) = workspace.update(cx, |cx| cx.window()) {
+                let answer = window.prompt(
+                    PromptLevel::Warning,
+                    "Leaving this call will unshare your current project.\nDo you want to switch channels?",
+                    &["Yes, Join Channel", "Cancel"],
+                    cx,
+                );
+
+                if let Some(mut answer) = answer {
+                    if answer.next().await == Some(1) {
+                        return Ok(false);
+                    }
+                }
+            } else {
+                return Ok(false); // unreachable!() hopefully
+            }
+        } else {
+            return Ok(false); // unreachable!() hopefully
+        }
+    }
+
+    let client = cx.read(|cx| active_call.read(cx).client());
+
+    let mut client_status = client.status();
+
+    // this loop will terminate within client::CONNECTION_TIMEOUT seconds.
+    'outer: loop {
+        let Some(status) = client_status.recv().await else {
+            return Err(anyhow!("error connecting"));
+        };
+
+        match status {
+            Status::Connecting
+            | Status::Authenticating
+            | Status::Reconnecting
+            | Status::Reauthenticating => continue,
+            Status::Connected { .. } => break 'outer,
+            Status::SignedOut => return Err(anyhow!("not signed in")),
+            Status::UpgradeRequired => return Err(anyhow!("zed is out of date")),
+            Status::ConnectionError | Status::ConnectionLost | Status::ReconnectionError { .. } => {
+                return Err(anyhow!("zed is offline"))
+            }
+        }
+    }
+
+    let room = active_call
+        .update(cx, |active_call, cx| {
+            active_call.join_channel(channel_id, cx)
+        })
+        .await?;
+
+    room.update(cx, |room, _| room.room_update_completed())
+        .await;
+
+    let task = room.update(cx, |room, cx| {
+        if let Some((project, host)) = room.most_active_project(cx) {
+            return Some(join_remote_project(project, host, app_state.clone(), cx));
+        }
+
+        None
+    });
+    if let Some(task) = task {
+        task.await?;
+        return anyhow::Ok(true);
+    }
+    anyhow::Ok(false)
+}
+
+pub fn join_channel(
+    channel_id: u64,
+    app_state: Arc<AppState>,
+    requesting_window: Option<WindowHandle<Workspace>>,
+    cx: &mut AppContext,
+) -> Task<Result<()>> {
+    let active_call = ActiveCall::global(cx);
+    cx.spawn(|mut cx| async move {
+        let result = join_channel_internal(
+            channel_id,
+            &app_state,
+            requesting_window,
+            &active_call,
+            &mut cx,
+        )
+        .await;
+
+        // join channel succeeded, and opened a window
+        if matches!(result, Ok(true)) {
+            return anyhow::Ok(());
+        }
+
+        if requesting_window.is_some() {
+            return anyhow::Ok(());
+        }
+
+        // find an existing workspace to focus and show call controls
+        let mut active_window = activate_any_workspace_window(&mut cx);
+        if active_window.is_none() {
+            // no open workspaces, make one to show the error in (blergh)
+            cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), requesting_window, cx))
+                .await;
+        }
+
+        active_window = activate_any_workspace_window(&mut cx);
+        if active_window.is_none() {
+            return result.map(|_| ()); // unreachable!() assuming new_local always opens a window
+        }
+
+        if let Err(err) = result {
+            let prompt = active_window.unwrap().prompt(
+                PromptLevel::Critical,
+                &format!("Failed to join channel: {}", err),
+                &["Ok"],
+                &mut cx,
+            );
+            if let Some(mut prompt) = prompt {
+                prompt.next().await;
+            } else {
+                return Err(err);
+            }
+        }
+
+        // return ok, we showed the error to the user.
+        return anyhow::Ok(());
+    })
+}
+
+pub fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option<AnyWindowHandle> {
+    for window in cx.windows() {
+        let found = window.update(cx, |cx| {
+            let is_workspace = cx.root_view().clone().downcast::<Workspace>().is_some();
+            if is_workspace {
+                cx.activate_window();
+            }
+            is_workspace
+        });
+        if found == Some(true) {
+            return Some(window);
+        }
+    }
+    None
+}
+
 #[allow(clippy::type_complexity)]
 pub fn open_paths(
     abs_paths: &[PathBuf],

crates/zed/Cargo.toml 🔗

@@ -162,6 +162,7 @@ identifier = "dev.zed.Zed-Dev"
 name = "Zed Dev"
 osx_minimum_system_version = "10.15.7"
 osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed-dev"]
 
 [package.metadata.bundle-preview]
 icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"]
@@ -169,6 +170,7 @@ identifier = "dev.zed.Zed-Preview"
 name = "Zed Preview"
 osx_minimum_system_version = "10.15.7"
 osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed-preview"]
 
 
 [package.metadata.bundle-stable]
@@ -177,3 +179,4 @@ identifier = "dev.zed.Zed"
 name = "Zed"
 osx_minimum_system_version = "10.15.7"
 osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed"]

crates/zed/src/main.rs 🔗

@@ -7,7 +7,9 @@ use cli::{
     ipc::{self, IpcSender},
     CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
 };
-use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{
+    self, Client, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
+};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{scroll::autoscroll::Autoscroll, Editor};
 use futures::{
@@ -31,12 +33,10 @@ use std::{
     ffi::OsStr,
     fs::OpenOptions,
     io::{IsTerminal, Write as _},
-    os::unix::prelude::OsStrExt,
     panic,
-    path::{Path, PathBuf},
-    str,
+    path::Path,
     sync::{
-        atomic::{AtomicBool, AtomicU32, Ordering},
+        atomic::{AtomicU32, Ordering},
         Arc, Weak,
     },
     thread,
@@ -44,7 +44,7 @@ use std::{
 };
 use sum_tree::Bias;
 use util::{
-    channel::ReleaseChannel,
+    channel::{parse_zed_link, ReleaseChannel},
     http::{self, HttpClient},
     paths::PathLikeWithPosition,
 };
@@ -60,6 +60,10 @@ use zed::{
     only_instance::{ensure_only_instance, IsOnlyInstance},
 };
 
+use crate::open_listener::{OpenListener, OpenRequest};
+
+mod open_listener;
+
 fn main() {
     let http = http::client();
     init_paths();
@@ -92,29 +96,20 @@ fn main() {
         })
     };
 
-    let (cli_connections_tx, mut cli_connections_rx) = mpsc::unbounded();
-    let cli_connections_tx = Arc::new(cli_connections_tx);
-    let (open_paths_tx, mut open_paths_rx) = mpsc::unbounded();
-    let open_paths_tx = Arc::new(open_paths_tx);
-    let urls_callback_triggered = Arc::new(AtomicBool::new(false));
-
-    let callback_cli_connections_tx = Arc::clone(&cli_connections_tx);
-    let callback_open_paths_tx = Arc::clone(&open_paths_tx);
-    let callback_urls_callback_triggered = Arc::clone(&urls_callback_triggered);
-    app.on_open_urls(move |urls, _| {
-        callback_urls_callback_triggered.store(true, Ordering::Release);
-        open_urls(urls, &callback_cli_connections_tx, &callback_open_paths_tx);
-    })
-    .on_reopen(move |cx| {
-        if cx.has_global::<Weak<AppState>>() {
-            if let Some(app_state) = cx.global::<Weak<AppState>>().upgrade() {
-                workspace::open_new(&app_state, cx, |workspace, cx| {
-                    Editor::new_file(workspace, &Default::default(), cx)
-                })
-                .detach();
+    let (listener, mut open_rx) = OpenListener::new();
+    let listener = Arc::new(listener);
+    let callback_listener = listener.clone();
+    app.on_open_urls(move |urls, _| callback_listener.open_urls(urls))
+        .on_reopen(move |cx| {
+            if cx.has_global::<Weak<AppState>>() {
+                if let Some(app_state) = cx.global::<Weak<AppState>>().upgrade() {
+                    workspace::open_new(&app_state, cx, |workspace, cx| {
+                        Editor::new_file(workspace, &Default::default(), cx)
+                    })
+                    .detach();
+                }
             }
-        }
-    });
+        });
 
     app.run(move |cx| {
         cx.set_global(*RELEASE_CHANNEL);
@@ -210,12 +205,9 @@ fn main() {
 
         if stdout_is_a_pty() {
             cx.platform().activate(true);
-            let paths = collect_path_args();
-            if paths.is_empty() {
-                cx.spawn(|cx| async move { restore_or_create_workspace(&app_state, cx).await })
-                    .detach()
-            } else {
-                workspace::open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx);
+            let urls = collect_url_args();
+            if !urls.is_empty() {
+                listener.open_urls(urls)
             }
         } else {
             upload_previous_panics(http.clone(), cx);
@@ -223,61 +215,85 @@ fn main() {
             // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead
             // of an *app, hence gets no specific callbacks run. Emulate them here, if needed.
             if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some()
-                && !urls_callback_triggered.load(Ordering::Acquire)
+                && !listener.triggered.load(Ordering::Acquire)
             {
-                open_urls(collect_url_args(), &cli_connections_tx, &open_paths_tx)
+                listener.open_urls(collect_url_args())
             }
+        }
 
-            if let Ok(Some(connection)) = cli_connections_rx.try_next() {
-                cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
-                    .detach();
-            } else if let Ok(Some(paths)) = open_paths_rx.try_next() {
+        let mut triggered_authentication = false;
+
+        match open_rx.try_next() {
+            Ok(Some(OpenRequest::Paths { paths })) => {
                 cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
                     .detach();
-            } else {
-                cx.spawn({
+            }
+            Ok(Some(OpenRequest::CliConnection { connection })) => {
+                cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
+                    .detach();
+            }
+            Ok(Some(OpenRequest::JoinChannel { channel_id })) => {
+                triggered_authentication = true;
+                let app_state = app_state.clone();
+                let client = client.clone();
+                cx.spawn(|mut cx| async move {
+                    // ignore errors here, we'll show a generic "not signed in"
+                    let _ = authenticate(client, &cx).await;
+                    cx.update(|cx| workspace::join_channel(channel_id, app_state, None, cx))
+                        .await
+                })
+                .detach_and_log_err(cx)
+            }
+            Ok(None) | Err(_) => cx
+                .spawn({
                     let app_state = app_state.clone();
                     |cx| async move { restore_or_create_workspace(&app_state, cx).await }
                 })
-                .detach()
-            }
-
-            cx.spawn(|cx| {
-                let app_state = app_state.clone();
-                async move {
-                    while let Some(connection) = cli_connections_rx.next().await {
-                        handle_cli_connection(connection, app_state.clone(), cx.clone()).await;
-                    }
-                }
-            })
-            .detach();
-
-            cx.spawn(|mut cx| {
-                let app_state = app_state.clone();
-                async move {
-                    while let Some(paths) = open_paths_rx.next().await {
-                        cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
-                            .detach();
-                    }
-                }
-            })
-            .detach();
+                .detach(),
         }
 
-        cx.spawn(|cx| async move {
-            if stdout_is_a_pty() {
-                if client::IMPERSONATE_LOGIN.is_some() {
-                    client.authenticate_and_connect(false, &cx).await?;
+        cx.spawn(|mut cx| {
+            let app_state = app_state.clone();
+            async move {
+                while let Some(request) = open_rx.next().await {
+                    match request {
+                        OpenRequest::Paths { paths } => {
+                            cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
+                                .detach();
+                        }
+                        OpenRequest::CliConnection { connection } => {
+                            cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
+                                .detach();
+                        }
+                        OpenRequest::JoinChannel { channel_id } => cx
+                            .update(|cx| {
+                                workspace::join_channel(channel_id, app_state.clone(), None, cx)
+                            })
+                            .detach(),
+                    }
                 }
-            } else if client.has_keychain_credentials(&cx) {
-                client.authenticate_and_connect(true, &cx).await?;
             }
-            Ok::<_, anyhow::Error>(())
         })
-        .detach_and_log_err(cx);
+        .detach();
+
+        if !triggered_authentication {
+            cx.spawn(|cx| async move { authenticate(client, &cx).await })
+                .detach_and_log_err(cx);
+        }
     });
 }
 
+async fn authenticate(client: Arc<Client>, cx: &AsyncAppContext) -> Result<()> {
+    if stdout_is_a_pty() {
+        if client::IMPERSONATE_LOGIN.is_some() {
+            client.authenticate_and_connect(false, &cx).await?;
+        }
+    } else if client.has_keychain_credentials(&cx) {
+        client.authenticate_and_connect(true, &cx).await?;
+    }
+    Ok::<_, anyhow::Error>(())
+}
+
 async fn installation_id() -> Result<String> {
     let legacy_key_name = "device_id";
 
@@ -294,37 +310,6 @@ async fn installation_id() -> Result<String> {
     }
 }
 
-fn open_urls(
-    urls: Vec<String>,
-    cli_connections_tx: &mpsc::UnboundedSender<(
-        mpsc::Receiver<CliRequest>,
-        IpcSender<CliResponse>,
-    )>,
-    open_paths_tx: &mpsc::UnboundedSender<Vec<PathBuf>>,
-) {
-    if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) {
-        if let Some(cli_connection) = connect_to_cli(server_name).log_err() {
-            cli_connections_tx
-                .unbounded_send(cli_connection)
-                .map_err(|_| anyhow!("no listener for cli connections"))
-                .log_err();
-        };
-    } else {
-        let paths: Vec<_> = urls
-            .iter()
-            .flat_map(|url| url.strip_prefix("file://"))
-            .map(|url| {
-                let decoded = urlencoding::decode_binary(url.as_bytes());
-                PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
-            })
-            .collect();
-        open_paths_tx
-            .unbounded_send(paths)
-            .map_err(|_| anyhow!("no listener for open urls requests"))
-            .log_err();
-    }
-}
-
 async fn restore_or_create_workspace(app_state: &Arc<AppState>, mut cx: AsyncAppContext) {
     if let Some(location) = workspace::last_opened_workspace_paths().await {
         cx.update(|cx| workspace::open_paths(location.paths().as_ref(), app_state, None, cx))
@@ -634,23 +619,23 @@ fn stdout_is_a_pty() -> bool {
     std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && std::io::stdout().is_terminal()
 }
 
-fn collect_path_args() -> Vec<PathBuf> {
+fn collect_url_args() -> Vec<String> {
     env::args()
         .skip(1)
-        .filter_map(|arg| match std::fs::canonicalize(arg) {
-            Ok(path) => Some(path),
+        .filter_map(|arg| match std::fs::canonicalize(Path::new(&arg)) {
+            Ok(path) => Some(format!("file://{}", path.to_string_lossy())),
             Err(error) => {
-                log::error!("error parsing path argument: {}", error);
-                None
+                if let Some(_) = parse_zed_link(&arg) {
+                    Some(arg)
+                } else {
+                    log::error!("error parsing path argument: {}", error);
+                    None
+                }
             }
         })
         .collect()
 }
 
-fn collect_url_args() -> Vec<String> {
-    env::args().skip(1).collect()
-}
-
 fn load_embedded_fonts(app: &App) {
     let font_paths = Assets.list("fonts");
     let embedded_fonts = Mutex::new(Vec::new());

crates/zed/src/open_listener.rs 🔗

@@ -0,0 +1,98 @@
+use anyhow::anyhow;
+use cli::{ipc::IpcSender, CliRequest, CliResponse};
+use futures::channel::mpsc;
+use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use std::ffi::OsStr;
+use std::os::unix::prelude::OsStrExt;
+use std::sync::atomic::Ordering;
+use std::{path::PathBuf, sync::atomic::AtomicBool};
+use util::channel::parse_zed_link;
+use util::ResultExt;
+
+use crate::connect_to_cli;
+
+pub enum OpenRequest {
+    Paths {
+        paths: Vec<PathBuf>,
+    },
+    CliConnection {
+        connection: (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
+    },
+    JoinChannel {
+        channel_id: u64,
+    },
+}
+
+pub struct OpenListener {
+    tx: UnboundedSender<OpenRequest>,
+    pub triggered: AtomicBool,
+}
+
+impl OpenListener {
+    pub fn new() -> (Self, UnboundedReceiver<OpenRequest>) {
+        let (tx, rx) = mpsc::unbounded();
+        (
+            OpenListener {
+                tx,
+                triggered: AtomicBool::new(false),
+            },
+            rx,
+        )
+    }
+
+    pub fn open_urls(&self, urls: Vec<String>) {
+        self.triggered.store(true, Ordering::Release);
+        let request = if let Some(server_name) =
+            urls.first().and_then(|url| url.strip_prefix("zed-cli://"))
+        {
+            self.handle_cli_connection(server_name)
+        } else if let Some(request_path) = urls.first().and_then(|url| parse_zed_link(url)) {
+            self.handle_zed_url_scheme(request_path)
+        } else {
+            self.handle_file_urls(urls)
+        };
+
+        if let Some(request) = request {
+            self.tx
+                .unbounded_send(request)
+                .map_err(|_| anyhow!("no listener for open requests"))
+                .log_err();
+        }
+    }
+
+    fn handle_cli_connection(&self, server_name: &str) -> Option<OpenRequest> {
+        if let Some(connection) = connect_to_cli(server_name).log_err() {
+            return Some(OpenRequest::CliConnection { connection });
+        }
+
+        None
+    }
+
+    fn handle_zed_url_scheme(&self, request_path: &str) -> Option<OpenRequest> {
+        let mut parts = request_path.split("/");
+        if parts.next() == Some("channel") {
+            if let Some(slug) = parts.next() {
+                if let Some(id_str) = slug.split("-").last() {
+                    if let Ok(channel_id) = id_str.parse::<u64>() {
+                        return Some(OpenRequest::JoinChannel { channel_id });
+                    }
+                }
+            }
+        }
+        log::error!("invalid zed url: {}", request_path);
+        None
+    }
+
+    fn handle_file_urls(&self, urls: Vec<String>) -> Option<OpenRequest> {
+        let paths: Vec<_> = urls
+            .iter()
+            .flat_map(|url| url.strip_prefix("file://"))
+            .map(|url| {
+                let decoded = urlencoding::decode_binary(url.as_bytes());
+                PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
+            })
+            .collect();
+
+        Some(OpenRequest::Paths { paths })
+    }
+}

script/bundle 🔗

@@ -5,6 +5,7 @@ set -e
 build_flag="--release"
 target_dir="release"
 open_result=false
+local_arch=false
 local_only=false
 overwrite_local_app=false
 bundle_name=""
@@ -16,8 +17,8 @@ Usage: ${0##*/} [options] [bundle_name]
 Build the application bundle.
 
 Options:
-  -d    Compile in debug mode and print the app bundle's path.
-  -l    Compile for local architecture only and copy bundle to /Applications.
+  -d    Compile in debug mode
+  -l    Compile for local architecture and copy bundle to /Applications, implies -d.
   -o    Open the resulting DMG or the app itself in local mode.
   -f    Overwrite the local app bundle if it exists.
   -h    Display this help and exit.
@@ -32,10 +33,20 @@ do
     case "${flag}" in
         o) open_result=true;;
         d)
+            export CARGO_INCREMENTAL=true
+            export CARGO_BUNDLE_SKIP_BUILD=true
             build_flag="";
+            local_arch=true
+            target_dir="debug"
+            ;;
+        l)
+            export CARGO_INCREMENTAL=true
+            export CARGO_BUNDLE_SKIP_BUILD=true
+            build_flag=""
+            local_arch=true
+            local_only=true
             target_dir="debug"
             ;;
-        l) local_only=true;;
         f) overwrite_local_app=true;;
         h)
            help_info
@@ -67,7 +78,7 @@ version_info=$(rustc --version --verbose)
 host_line=$(echo "$version_info" | grep host)
 local_target_triple=${host_line#*: }
 
-if [ "$local_only" = true ]; then
+if [ "$local_arch" = true ]; then
     echo "Building for local target only."
     cargo build ${build_flag} --package zed
     cargo build ${build_flag} --package cli
@@ -91,7 +102,7 @@ sed \
     "s/package.metadata.bundle-${channel}/package.metadata.bundle/" \
     Cargo.toml
 
-if [ "$local_only" = true ]; then
+if [ "$local_arch" = true ]; then
     app_path=$(cargo bundle ${build_flag} --select-workspace-root | xargs)
 else
     app_path=$(cargo bundle ${build_flag} --target x86_64-apple-darwin --select-workspace-root | xargs)
@@ -101,7 +112,7 @@ mv Cargo.toml.backup Cargo.toml
 popd
 echo "Bundled ${app_path}"
 
-if [ "$local_only" = false ]; then
+if [ "$local_arch" = false ]; then
     echo "Creating fat binaries"
     lipo \
         -create \
@@ -117,7 +128,11 @@ fi
 
 echo "Copying WebRTC.framework into the frameworks folder"
 mkdir "${app_path}/Contents/Frameworks"
-cp -R target/${local_target_triple}/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+if [ "$local_arch" = false ]; then
+    cp -R target/${local_target_triple}/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+else
+    cp -R target/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+fi
 
 if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
     echo "Signing bundle with Apple-issued certificate"
@@ -133,10 +148,12 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR
 else
     echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD"
     echo "Performing an ad-hoc signature, but this bundle should not be distributed"
-    codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign - "${app_path}" -v
+    echo "If you see 'The application cannot be opened for an unexpected reason,' you likely don't have the necessary entitlements to run the application in your signing keychain"
+    echo "You will need to download a new signing key from developer.apple.com, add it to keychain, and export MACOS_SIGNING_KEY=<email address of signing key>"
+    codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
 fi
 
-if [ "$target_dir" = "debug" ]; then
+if [[ "$target_dir" = "debug" && "$local_only" = false ]]; then
     if [ "$open_result" = true ]; then
         open "$app_path"
     else