Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01d2fbef3 | ||
|
|
c3d677ca81 |
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.1"
|
version = "1.0.0-beta5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ schemars = "1"
|
|||||||
smallvec = "1.14.0"
|
smallvec = "1.14.0"
|
||||||
smol = "2"
|
smol = "2"
|
||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,125 @@
|
|||||||
|

|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
|
||||||
|
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
|
||||||
|
</a>
|
||||||
|
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
|
||||||
|
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
|
||||||
|
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
|
||||||
|
</p>
|
||||||
|
|
||||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
<p float="left">
|
||||||
|
<img src="/docs/mac_01.png" width="250" />
|
||||||
|
<img src="/docs/mac_02.png" width="250" />
|
||||||
|
<img src="/docs/mac_03.png" width="250" />
|
||||||
|
<img src="/docs/mac_04.png" width="250" />
|
||||||
|
<img src="/docs/mac_05.png" width="250" />
|
||||||
|
<img src="/docs/mac_06.png" width="250" />
|
||||||
|
<img src="/docs/mac_07.png" width="250" />
|
||||||
|
<img src="/docs/mac_08.png" width="250" />
|
||||||
|
<img src="/docs/mac_09.png" width="250" />
|
||||||
|
<img src="/docs/linux_01.png" width="250" />
|
||||||
|
<img src="/docs/linux_02.png" width="250" />
|
||||||
|
<img src="/docs/linux_03.png" width="250" />
|
||||||
|
<img src="/docs/linux_04.png" width="250" />
|
||||||
|
<img src="/docs/linux_05.png" width="250" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
To install Coop, follow these steps:
|
||||||
|
|
||||||
|
1. **Download the Latest Release**:
|
||||||
|
|
||||||
|
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
|
||||||
|
- Download the package that matches your operating system (Windows, macOS, or Linux).
|
||||||
|
|
||||||
|
2. **Install**:
|
||||||
|
|
||||||
|
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
||||||
|
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
||||||
|
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
||||||
|
|
||||||
|
3. **Run Coop**:
|
||||||
|
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
||||||
|
|
||||||
|
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
|
||||||
|
|
||||||
|
### Developing Coop
|
||||||
|
|
||||||
|
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
|
||||||
|
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
|
||||||
|
- **Git**: To clone the repository and manage version control.
|
||||||
|
|
||||||
|
#### Setting Up the Development Environment
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/reyakov/coop.git
|
||||||
|
cd coop
|
||||||
|
```
|
||||||
|
|
||||||
|
2.1 Install Linux dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/linux
|
||||||
|
```
|
||||||
|
|
||||||
|
2.2 Install FreeBSD dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/freebsd
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install Rust dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the app:
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Building for Production
|
||||||
|
|
||||||
|
To build Coop for production, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
This will generate an optimized binary in the `target/release` directory.
|
||||||
|
|
||||||
|
#### Contributing Code
|
||||||
|
|
||||||
|
If you'd like to contribute to Coop, please follow these steps:
|
||||||
|
|
||||||
|
1. Fork the repository.
|
||||||
|
2. Create a new branch for your feature or bugfix.
|
||||||
|
3. Make your changes and ensure all tests pass.
|
||||||
|
4. Submit a pull request with a detailed description of your changes.
|
||||||
|
|
||||||
|
For more information, see the [Contributing](#contributing) section.
|
||||||
|
|
||||||
|
#### Additional Resources
|
||||||
|
|
||||||
|
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||||
|
- [GPUI](https://www.gpui.rs/)
|
||||||
|
- [GPUI Components](https://github.com/longbridge/gpui-component/)
|
||||||
|
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
Copyright (C) 2025 Ren Amamiya & other Coop contributors
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
|
semver = "1.0.27"
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
smol.workspace = true
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
|
use std::ffi::OsString;
|
||||||
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
|
use std::path::{Path, PathBuf};
|
||||||
use instant::{Duration, Instant};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use instant::Duration;
|
||||||
|
use semver::Version;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use smallvec::{SmallVec, smallvec};
|
||||||
|
use smol::fs::File;
|
||||||
|
use smol::io::AsyncReadExt;
|
||||||
|
use smol::process::Command;
|
||||||
|
|
||||||
|
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||||
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
|
|
||||||
|
|
||||||
fn get_github_repo_owner() -> String {
|
fn get_github_repo_owner() -> String {
|
||||||
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
|
||||||
@@ -15,27 +29,15 @@ fn get_github_repo_name() -> String {
|
|||||||
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether updates are managed by an external distribution channel
|
fn is_flatpak_installation() -> bool {
|
||||||
/// (Flatpak/Snap), in which case the in-app updater must not run.
|
// Check if app is installed via Flatpak
|
||||||
fn uses_managed_updates() -> bool {
|
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||||
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
|
|
||||||
std::env::var("FLATPAK_ID").is_ok()
|
|
||||||
// Allow opting out of in-app updates via an explicit environment variable.
|
|
||||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
|
||||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
|
||||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the auto-update system.
|
|
||||||
///
|
|
||||||
/// Skips initialization when updates are handled by an external distribution
|
|
||||||
/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`]
|
|
||||||
/// entity and schedules a check for updates after a 2-minute delay.
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
if uses_managed_updates() {
|
// Skip auto-update initialization if installed via Flatpak
|
||||||
log::info!(
|
if is_flatpak_installation() {
|
||||||
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
|
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,165 +48,516 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
|
#[cfg(not(target_os = "windows"))]
|
||||||
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
|
struct InstallerDir(tempfile::TempDir);
|
||||||
|
|
||||||
/// The global auto-updater entity.
|
#[cfg(not(target_os = "windows"))]
|
||||||
///
|
impl InstallerDir {
|
||||||
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
|
async fn new() -> Result<Self, Error> {
|
||||||
/// (GitHub repo, Flatpak detection, delayed auto-check).
|
Ok(Self(
|
||||||
///
|
tempfile::Builder::new()
|
||||||
/// Retrieve the global instance via [`AutoUpdater::global`].
|
.prefix("coop-auto-update")
|
||||||
|
.tempdir()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
struct InstallerDir(PathBuf);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
impl InstallerDir {
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
let installer_dir = std::env::current_exe()?
|
||||||
|
.parent()
|
||||||
|
.context("No parent dir for Coop.exe")?
|
||||||
|
.join("updates");
|
||||||
|
|
||||||
|
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||||
|
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
smol::fs::create_dir(&installer_dir).await?;
|
||||||
|
|
||||||
|
Ok(Self(installer_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.as_path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacOsUnmounter<'a> {
|
||||||
|
mount_path: PathBuf,
|
||||||
|
background_executor: &'a BackgroundExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MacOsUnmounter<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mount_path = std::mem::take(&mut self.mount_path);
|
||||||
|
|
||||||
|
self.background_executor
|
||||||
|
.spawn(async move {
|
||||||
|
let unmount_output = Command::new("hdiutil")
|
||||||
|
.args(["detach", "-force"])
|
||||||
|
.arg(&mount_path)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match unmount_output {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
log::info!("Successfully unmounted the disk image");
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to unmount disk image: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum AutoUpdateStatus {
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
Checked { download_url: String },
|
||||||
|
Installing,
|
||||||
|
Updated,
|
||||||
|
Errored { msg: Box<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||||
|
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoUpdateStatus {
|
||||||
|
pub fn is_updating(&self) -> bool {
|
||||||
|
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_updated(&self) -> bool {
|
||||||
|
matches!(self, Self::Updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked(download_url: String) -> Self {
|
||||||
|
Self::Checked { download_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(e: String) -> Self {
|
||||||
|
Self::Errored { msg: Box::new(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GitHubRelease {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub assets: Vec<GitHubAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GitHubAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The underlying gpui-updater entity that does the heavy lifting.
|
/// Current status of the auto updater
|
||||||
pub updater: Entity<Updater>,
|
pub status: AutoUpdateStatus,
|
||||||
/// Currently running app version.
|
|
||||||
|
/// Current version of the application
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
/// Keeps the observer subscription alive.
|
|
||||||
_subscription: Subscription,
|
/// Event subscriptions
|
||||||
/// When the last error was recorded, so we can reset to idle after 5s.
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
error_time: Option<Instant>,
|
|
||||||
|
/// Background tasks
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Whether auto-update is available for this installation.
|
/// Retrieve the global auto updater instance
|
||||||
///
|
|
||||||
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
|
|
||||||
/// updates are handled by the channel and no global updater is created.
|
|
||||||
pub fn is_available(cx: &App) -> bool {
|
|
||||||
cx.try_global::<GlobalAutoUpdater>().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance, if one was initialized.
|
|
||||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
|
||||||
cx.try_global::<GlobalAutoUpdater>()
|
|
||||||
.map(|global| global.0.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the global auto updater instance.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics when auto-update is not available for this installation. Prefer
|
|
||||||
/// [`AutoUpdater::try_global`] when the installation type is not known at
|
|
||||||
/// compile time (e.g. Flatpak/Snap).
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the global auto updater instance
|
||||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
let repo_owner = get_github_repo_owner();
|
subscriptions.push(
|
||||||
let repo_name = get_github_repo_name();
|
// Observe the status
|
||||||
|
cx.observe_self(|this, cx| {
|
||||||
let source =
|
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||||
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
|
this.download_and_install(&download_url, cx);
|
||||||
"macos" => "macos",
|
|
||||||
"linux" => "linux",
|
|
||||||
_ => "",
|
|
||||||
});
|
|
||||||
|
|
||||||
let updater: Entity<Updater> =
|
|
||||||
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
|
|
||||||
|
|
||||||
// When an update becomes available, automatically download and install it.
|
|
||||||
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
|
|
||||||
let status = this.updater.read(cx).status().clone();
|
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Available(_)) {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.download_and_install(cx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if matches!(status, UpdateStatus::Errored(_)) {
|
// Run at the end of current cycle
|
||||||
this.error_time = Some(Instant::now());
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
cx.spawn(async move |this, cx| {
|
this.check(cx);
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
|
||||||
this.update(cx, |_this, cx| cx.notify()).ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
} else {
|
|
||||||
this.error_time = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schedule an auto-check after a 2-minute delay (deferred to run at the
|
|
||||||
// end of the current frame so the window is fully set up).
|
|
||||||
cx.defer_in(window, |_this, _window, cx| {
|
|
||||||
let duration = Duration::from_secs(120);
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(duration).await;
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
updater,
|
status: AutoUpdateStatus::Idle,
|
||||||
version,
|
version,
|
||||||
_subscription: subscription,
|
tasks: vec![],
|
||||||
error_time: None,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn idle(&self, cx: &App) -> bool {
|
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||||
let status = self.updater.read(cx).status();
|
self.status = status;
|
||||||
if status == &UpdateStatus::Idle {
|
cx.notify();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if matches!(status, UpdateStatus::Errored(_))
|
|
||||||
&& self
|
|
||||||
.error_time
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self, cx: &App) -> SharedString {
|
fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
let status = self.updater.read(cx).status();
|
let version = self.version.clone();
|
||||||
|
let duration = Duration::from_secs(120);
|
||||||
|
let task = self.check_for_updates(version, cx);
|
||||||
|
|
||||||
match status {
|
// Check for updates after 2 minutes
|
||||||
UpdateStatus::Idle => "Up to date".into(),
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
UpdateStatus::Checking => "Checking for updates…".into(),
|
cx.background_executor().timer(duration).await;
|
||||||
UpdateStatus::UpToDate => "Up to date".into(),
|
|
||||||
UpdateStatus::Available(version) => format!("Version {version} available").into(),
|
// Update the status to checking
|
||||||
UpdateStatus::Downloading { downloaded, total } => {
|
this.update(cx, |this, cx| {
|
||||||
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
|
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||||
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
|
})?;
|
||||||
match total_mb {
|
|
||||||
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
|
match task.await {
|
||||||
None => format!("Downloading {downloaded_mb:.1} MB").into(),
|
Ok(download_url) => {
|
||||||
|
// Update the status to checked with download URL
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to check for updates: {e}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateStatus::Installing => "Installing update…".into(),
|
|
||||||
UpdateStatus::Staged(version) => {
|
Ok(())
|
||||||
format!("Version {version} ready — restart to apply").into()
|
}));
|
||||||
}
|
}
|
||||||
UpdateStatus::Errored(msg) => {
|
|
||||||
if self
|
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
|
||||||
.error_time
|
let http_client = cx.http_client();
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
|
let repo_owner = get_github_repo_owner();
|
||||||
{
|
let repo_name = get_github_repo_name();
|
||||||
"Up to date".into()
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/releases/latest",
|
||||||
|
GITHUB_API_URL, repo_owner, repo_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let async_body = AsyncBody::default();
|
||||||
|
let mut body = Vec::new();
|
||||||
|
let mut response = http_client.get(&url, async_body, false).await?;
|
||||||
|
|
||||||
|
// Read the response body into a vector
|
||||||
|
response.body_mut().read_to_end(&mut body).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response body as JSON
|
||||||
|
let release: GitHubRelease = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
// Parse version from tag (remove 'v' prefix if present)
|
||||||
|
let tag_version = release.tag_name.trim_start_matches('v');
|
||||||
|
let new_version = Version::parse(tag_version).context(format!(
|
||||||
|
"Failed to parse version from tag: {}",
|
||||||
|
release.tag_name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
if new_version > version {
|
||||||
|
// Find the appropriate asset for the current platform
|
||||||
|
let current_os = std::env::consts::OS;
|
||||||
|
let asset_name = match current_os {
|
||||||
|
"macos" => "Coop.dmg",
|
||||||
|
"linux" => "coop.tar.gz",
|
||||||
|
"windows" => "Coop.exe",
|
||||||
|
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let download_url = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.name == asset_name)
|
||||||
|
.map(|asset| asset.browser_download_url.clone())
|
||||||
|
.context(format!(
|
||||||
|
"No {} asset found in release {}",
|
||||||
|
asset_name, release.tag_name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(download_url)
|
||||||
} else {
|
} else {
|
||||||
format!("Update failed: {msg}").into()
|
Err(anyhow!(
|
||||||
|
"No update available. Current: {}, Latest: {}",
|
||||||
|
version,
|
||||||
|
new_version
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
||||||
|
let http_client = cx.http_client();
|
||||||
|
let download_url = download_url.to_string();
|
||||||
|
|
||||||
|
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||||
|
let installer_dir = InstallerDir::new().await?;
|
||||||
|
let target_path = Self::target_path(&installer_dir).await?;
|
||||||
|
|
||||||
|
// Download the release
|
||||||
|
download(&download_url, &target_path, http_client).await?;
|
||||||
|
|
||||||
|
Ok((installer_dir, target_path))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tasks.push(
|
||||||
|
// Install the new release
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match task.await {
|
||||||
|
Ok((installer_dir, target_path)) => {
|
||||||
|
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
||||||
|
// Update the status to updated
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Update the status to error including the error message
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||||
|
let filename = match std::env::consts::OS {
|
||||||
|
"macos" => anyhow::Ok("Coop.dmg"),
|
||||||
|
"linux" => Ok("coop.tar.gz"),
|
||||||
|
"windows" => Ok("Coop.exe"),
|
||||||
|
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(installer_dir.path().join(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install(
|
||||||
|
installer_dir: InstallerDir,
|
||||||
|
target_path: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match std::env::consts::OS {
|
||||||
|
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||||
|
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
|
||||||
|
"windows" => install_release_windows(target_path).await,
|
||||||
|
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn download(
|
||||||
|
url: &str,
|
||||||
|
target_path: &std::path::Path,
|
||||||
|
client: Arc<dyn HttpClient>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let body = AsyncBody::default();
|
||||||
|
let mut target_file = File::create(&target_path).await?;
|
||||||
|
let mut response = client.get(url, body, true).await?;
|
||||||
|
|
||||||
|
// Copy the response body to the target file
|
||||||
|
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_macos(
|
||||||
|
temp_dir: &InstallerDir,
|
||||||
|
downloaded_dmg: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||||
|
let running_app_filename = running_app_path
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||||
|
|
||||||
|
let mount_path = temp_dir.path().join("Coop");
|
||||||
|
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||||
|
|
||||||
|
mounted_app_path.push("/");
|
||||||
|
|
||||||
|
let output = Command::new("hdiutil")
|
||||||
|
.args(["attach", "-nobrowse"])
|
||||||
|
.arg(&downloaded_dmg)
|
||||||
|
.arg("-mountroot")
|
||||||
|
.arg(temp_dir.path())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to mount: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||||
|
let _unmounter = MacOsUnmounter {
|
||||||
|
mount_path: mount_path.clone(),
|
||||||
|
background_executor: cx.background_executor(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new("rsync")
|
||||||
|
.args(["-av", "--delete"])
|
||||||
|
.arg(&mounted_app_path)
|
||||||
|
.arg(&running_app_path)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to copy app: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_linux(
|
||||||
|
temp_dir: &InstallerDir,
|
||||||
|
downloaded_tar_gz: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||||
|
|
||||||
|
// Extract the tar.gz file
|
||||||
|
let extracted = temp_dir.path().join("coop");
|
||||||
|
smol::fs::create_dir_all(&extracted)
|
||||||
|
.await
|
||||||
|
.context("failed to create directory to extract update")?;
|
||||||
|
|
||||||
|
let output = Command::new("tar")
|
||||||
|
.arg("-xzf")
|
||||||
|
.arg(&downloaded_tar_gz)
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&extracted)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to extract {:?} to {:?}: {:?}",
|
||||||
|
downloaded_tar_gz,
|
||||||
|
extracted,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the extracted app directory
|
||||||
|
let mut entries = smol::fs::read_dir(&extracted).await?;
|
||||||
|
let mut app_dir = None;
|
||||||
|
|
||||||
|
use smol::stream::StreamExt;
|
||||||
|
|
||||||
|
while let Some(entry) = entries.next().await {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
app_dir = Some(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = app_dir.context("No app directory found in archive")?;
|
||||||
|
|
||||||
|
// Copy to the current installation directory
|
||||||
|
let output = Command::new("rsync")
|
||||||
|
.args(["-av", "--delete"])
|
||||||
|
.arg(&from)
|
||||||
|
.arg(
|
||||||
|
running_app_path
|
||||||
|
.parent()
|
||||||
|
.context("No parent directory for app")?,
|
||||||
|
)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to copy app from {:?} to {:?}: {:?}",
|
||||||
|
from,
|
||||||
|
running_app_path.parent(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||||
|
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let system_root = std::env::var("SYSTEMROOT");
|
||||||
|
let powershell_path = system_root.as_ref().map_or_else(
|
||||||
|
|_| "powershell.exe".to_string(),
|
||||||
|
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut installer_path = std::ffi::OsString::new();
|
||||||
|
installer_path.push("\"");
|
||||||
|
installer_path.push(&downloaded_installer);
|
||||||
|
installer_path.push("\"");
|
||||||
|
|
||||||
|
let output = Command::new(powershell_path)
|
||||||
|
//.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||||
|
.args(["Start-Process"])
|
||||||
|
.arg(installer_path)
|
||||||
|
.arg("-ArgumentList")
|
||||||
|
.args(["/P", "/R"])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to start installer: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "browser-signer-proxy"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
|
|
||||||
license = "MIT"
|
|
||||||
repository = "https://github.com/nostrdevkit/nostr"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
atomic-destructor = "0.2"
|
|
||||||
event-listener = "5"
|
|
||||||
nostr.workspace = true
|
|
||||||
opaquerr = { version = "0.1", features = ["alloc"] }
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
tracing = { version = "0.1", features = ["std"] }
|
|
||||||
uuid = { version = "1.23", features = ["serde", "v4"] }
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# browser-signer-proxy
|
|
||||||
|
|
||||||
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
|
|
||||||
|
|
||||||
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
|
|
||||||
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
|
|
||||||
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
|
|
||||||
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
|
|
||||||
the browser extension.
|
|
||||||
|
|
||||||
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
|
|
||||||
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use browser_signer_proxy::prelude::*;
|
|
||||||
|
|
||||||
async fn example() -> Result<(), Error> {
|
|
||||||
// Create the proxy with default options (localhost:7400)
|
|
||||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
|
||||||
|
|
||||||
// Open the proxy URL in a browser
|
|
||||||
webbrowser::open(&proxy.url())?;
|
|
||||||
|
|
||||||
// Start the proxy server
|
|
||||||
proxy.start().await?;
|
|
||||||
|
|
||||||
// Use it as an async Nostr signer
|
|
||||||
let public_key = proxy.get_public_key_async().await?;
|
|
||||||
println!("Connected with public key: {public_key}");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Differences from the tokio-based version
|
|
||||||
|
|
||||||
| Feature | tokio (original) | smol (this crate) |
|
|
||||||
|---|---|---|
|
|
||||||
| Async runtime | `tokio` | `smol` |
|
|
||||||
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
|
|
||||||
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
|
|
||||||
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
|
|
||||||
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
|
|
||||||
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
|
|
||||||
| Task spawning | `tokio::spawn` | `smol::spawn` |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is distributed under the MIT software license.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Coop — Web Signer Proxy</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--brand: #F8FF37;
|
|
||||||
--ink: #111111;
|
|
||||||
--ink-soft: #333333;
|
|
||||||
--muted: #666666;
|
|
||||||
--paper: #FFFFFF;
|
|
||||||
--edge: rgba(17, 17, 17, 0.14);
|
|
||||||
--radius-sm: 1rem;
|
|
||||||
--radius-md: 1.5rem;
|
|
||||||
--radius-lg: 2.5rem;
|
|
||||||
--green: #2E8B57;
|
|
||||||
--red: #D32F2F;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--brand);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--paper);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2.5rem;
|
|
||||||
max-width: 440px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-bottom: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo__mark {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
background: var(--ink);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: "Nunito", system-ui, sans-serif;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--brand);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo__text {
|
|
||||||
font-family: "Nunito", system-ui, sans-serif;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: "Nunito", system-ui, sans-serif;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
line-height: 1.15;
|
|
||||||
margin: 0 0 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
margin: 0 0 1.75rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: background 300ms ease, color 300ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--checking {
|
|
||||||
background: rgba(17, 17, 17, 0.05);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--connected {
|
|
||||||
background: rgba(46, 139, 87, 0.1);
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--error {
|
|
||||||
background: rgba(211, 47, 47, 0.08);
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__dot {
|
|
||||||
width: 0.7rem;
|
|
||||||
height: 0.7rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--checking .status__dot {
|
|
||||||
background: var(--muted);
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--connected .status__dot {
|
|
||||||
background: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--error .status__dot {
|
|
||||||
background: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
|
||||||
50% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
border-top: 1px solid var(--edge);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint strong {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1 class="heading">Web Signer</h1>
|
|
||||||
|
|
||||||
<p class="subtitle">
|
|
||||||
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="nip07-status" class="status status--checking">
|
|
||||||
<div class="status__dot"></div>
|
|
||||||
<span id="nip07-status-text">Checking extension…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hint">
|
|
||||||
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="proxy.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
let isPolling = false;
|
|
||||||
|
|
||||||
async function pollForRequests() {
|
|
||||||
if (isPolling) return;
|
|
||||||
isPolling = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/pending');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log('Polled for requests, got:', data);
|
|
||||||
|
|
||||||
// Process any new requests
|
|
||||||
if (data.requests && data.requests.length > 0) {
|
|
||||||
console.log(`Processing ${data.requests.length} requests`);
|
|
||||||
for (const request of data.requests) {
|
|
||||||
await handleNip07Request(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Polling error:', error);
|
|
||||||
updateStatus('Error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
isPolling = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleNip07Request(request) {
|
|
||||||
console.log('Handling request:', request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result;
|
|
||||||
|
|
||||||
if (!window.nostr) {
|
|
||||||
throw new Error('NIP-07 extension not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (request.method) {
|
|
||||||
case 'get_public_key':
|
|
||||||
console.log('Calling nostr.getPublicKey()');
|
|
||||||
result = await window.nostr.getPublicKey();
|
|
||||||
console.log('Got public key:', result);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'sign_event':
|
|
||||||
console.log('Calling nostr.signEvent() with:', request.params);
|
|
||||||
result = await window.nostr.signEvent(request.params);
|
|
||||||
console.log('Got signed event:', result);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nip04_encrypt':
|
|
||||||
console.log('Calling nostr.nip04.encrypt()');
|
|
||||||
result = await window.nostr.nip04.encrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nip04_decrypt':
|
|
||||||
console.log('Calling nostr.nip04.decrypt()');
|
|
||||||
result = await window.nostr.nip04.decrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nip44_encrypt':
|
|
||||||
console.log('Calling nostr.nip44.encrypt()');
|
|
||||||
result = await window.nostr.nip44.encrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nip44_decrypt':
|
|
||||||
console.log('Calling nostr.nip44.decrypt()');
|
|
||||||
result = await window.nostr.nip44.decrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown method: ${request.method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send response back to server
|
|
||||||
const responsePayload = {
|
|
||||||
id: request.id,
|
|
||||||
result: result,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Sending response:', responsePayload);
|
|
||||||
|
|
||||||
await fetch('/api/response', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(responsePayload)
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Response sent successfully');
|
|
||||||
updateStatus('Request processed successfully', 'connected');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling request:', error);
|
|
||||||
|
|
||||||
// Send error response back to server
|
|
||||||
const errorPayload = {
|
|
||||||
id: request.id,
|
|
||||||
result: null,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Sending error response:', errorPayload);
|
|
||||||
|
|
||||||
await fetch('/api/response', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(errorPayload)
|
|
||||||
});
|
|
||||||
|
|
||||||
updateStatus('Error: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(message, state) {
|
|
||||||
const container = document.getElementById('nip07-status');
|
|
||||||
const textEl = document.getElementById('nip07-status-text');
|
|
||||||
if (container && textEl) {
|
|
||||||
container.className = 'status status--' + state;
|
|
||||||
textEl.textContent = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling when page loads
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
console.log('NIP-07 Proxy loaded');
|
|
||||||
|
|
||||||
// Check if NIP-07 extension is available
|
|
||||||
if (window.nostr) {
|
|
||||||
console.log('NIP-07 extension detected');
|
|
||||||
updateStatus('Connected — ready', 'connected');
|
|
||||||
} else {
|
|
||||||
console.log('NIP-07 extension not found');
|
|
||||||
updateStatus('No NIP-07 extension found', 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling every 500 ms
|
|
||||||
setInterval(pollForRequests, 500);
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
|
||||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
|
||||||
// Distributed under the MIT software license
|
|
||||||
|
|
||||||
//! Error types for the browser signer proxy.
|
|
||||||
|
|
||||||
opaquerr::define_kind! {
|
|
||||||
/// Nostr browser signer proxy error kind.
|
|
||||||
pub ErrorKind {
|
|
||||||
/// Nostr protocol error.
|
|
||||||
Protocol => "nostr protocol error",
|
|
||||||
/// I/O error.
|
|
||||||
IO => "I/O error",
|
|
||||||
/// JSON error.
|
|
||||||
Json => "JSON error",
|
|
||||||
/// The operation timed out.
|
|
||||||
Timeout => "timeout",
|
|
||||||
/// The operation cannot be completed in the current state.
|
|
||||||
State => "invalid state",
|
|
||||||
/// Anything not covered by the stable categories above.
|
|
||||||
Other => "other error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
opaquerr::define_error! {
|
|
||||||
/// Nostr browser signer proxy error.
|
|
||||||
pub Error(ErrorKind)
|
|
||||||
|
|
||||||
from {
|
|
||||||
nostr::error::Error => ErrorKind::Protocol,
|
|
||||||
std::io::Error => ErrorKind::IO,
|
|
||||||
serde_json::Error => ErrorKind::Json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error {
|
|
||||||
pub(crate) fn generic<S>(message: S) -> Self
|
|
||||||
where
|
|
||||||
S: Into<String>,
|
|
||||||
{
|
|
||||||
Self::new(ErrorKind::Other, message.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn timeout() -> Self {
|
|
||||||
Self::simple(ErrorKind::Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn shutdown() -> Self {
|
|
||||||
Self::with_static_message(ErrorKind::State, "server is shutdown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,764 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
|
||||||
use event_listener::Event as ShutdownEvent;
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use smol::channel;
|
|
||||||
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use smol::lock::Mutex;
|
|
||||||
use smol::net::{TcpListener, TcpStream};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
mod error;
|
|
||||||
pub mod prelude;
|
|
||||||
|
|
||||||
pub use self::error::Error;
|
|
||||||
|
|
||||||
const DEFAULT_HTML: &str = include_str!("../index.html");
|
|
||||||
const JS: &str = include_str!("../proxy.js");
|
|
||||||
|
|
||||||
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Message {
|
|
||||||
id: Uuid,
|
|
||||||
error: Option<String>,
|
|
||||||
result: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
fn into_result(self) -> Result<Value, String> {
|
|
||||||
if let Some(error) = self.error {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self.result.unwrap_or(Value::Null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum RequestMethod {
|
|
||||||
GetPublicKey,
|
|
||||||
SignEvent,
|
|
||||||
Nip04Encrypt,
|
|
||||||
Nip04Decrypt,
|
|
||||||
Nip44Encrypt,
|
|
||||||
Nip44Decrypt,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestMethod {
|
|
||||||
fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::GetPublicKey => "get_public_key",
|
|
||||||
Self::SignEvent => "sign_event",
|
|
||||||
Self::Nip04Encrypt => "nip04_encrypt",
|
|
||||||
Self::Nip04Decrypt => "nip04_decrypt",
|
|
||||||
Self::Nip44Encrypt => "nip44_encrypt",
|
|
||||||
Self::Nip44Decrypt => "nip44_decrypt",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for RequestMethod {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
struct RequestData {
|
|
||||||
id: Uuid,
|
|
||||||
method: RequestMethod,
|
|
||||||
params: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestData {
|
|
||||||
#[inline]
|
|
||||||
fn new(method: RequestMethod, params: Value) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Requests<'a> {
|
|
||||||
requests: &'a [RequestData],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Requests<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(requests: &'a [RequestData]) -> Self {
|
|
||||||
Self { requests }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.requests.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Params for NIP-04 and NIP-44 encryption/decryption
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct CryptoParams<'a> {
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> CryptoParams<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ProxyState {
|
|
||||||
/// Requests waiting to be picked up by browser
|
|
||||||
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
|
||||||
/// Map of request ID to response sender
|
|
||||||
pub pending_responses: Mutex<PendingResponseMap>,
|
|
||||||
/// Last time the client asked for the pending requests
|
|
||||||
pub last_pending_request: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration options for [`BrowserSignerProxy`].
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BrowserSignerProxyOptions {
|
|
||||||
/// Request timeout for the signer extension. Default is 30 seconds.
|
|
||||||
pub timeout: Duration,
|
|
||||||
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
/// Custom HTML page.
|
|
||||||
// NOTE: not `Option` to move it between threads without reference counter
|
|
||||||
pub custom_html: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct InnerBrowserSignerProxy {
|
|
||||||
/// Configuration options for the proxy
|
|
||||||
options: BrowserSignerProxyOptions,
|
|
||||||
/// Internal state of the proxy including request queues
|
|
||||||
state: Arc<ProxyState>,
|
|
||||||
/// Notification trigger for graceful shutdown
|
|
||||||
shutdown: Arc<ShutdownEvent>,
|
|
||||||
/// Flag to indicate if the server is shutdown
|
|
||||||
is_shutdown: Arc<AtomicBool>,
|
|
||||||
/// Flag indicating if the server is started
|
|
||||||
is_started: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
|
||||||
fn on_destroy(&self) {
|
|
||||||
self.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InnerBrowserSignerProxy {
|
|
||||||
#[inline]
|
|
||||||
fn is_shutdown(&self) -> bool {
|
|
||||||
self.is_shutdown.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(&self) {
|
|
||||||
// Mark the server as shutdown
|
|
||||||
self.is_shutdown.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Notify all waiters that the proxy is shutting down
|
|
||||||
self.shutdown.notify(usize::MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nostr Browser Signer Proxy
|
|
||||||
///
|
|
||||||
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BrowserSignerProxy {
|
|
||||||
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BrowserSignerProxyOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout: Duration::from_secs(30),
|
|
||||||
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
|
||||||
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
|
||||||
custom_html: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxyOptions {
|
|
||||||
/// Sets the timeout duration.
|
|
||||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
|
||||||
self.timeout = timeout;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the IP address.
|
|
||||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
|
||||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the port number.
|
|
||||||
pub const fn port(mut self, new_port: u16) -> Self {
|
|
||||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets a custom html page.
|
|
||||||
///
|
|
||||||
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
|
|
||||||
/// which will handle communication with the server and update the element
|
|
||||||
/// with id `nip07-proxy-status` with the status.
|
|
||||||
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
|
|
||||||
self.custom_html = custom_html;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxy {
|
|
||||||
/// Construct a new browser signer proxy
|
|
||||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
|
||||||
let state = ProxyState {
|
|
||||||
outgoing_requests: Mutex::new(Vec::new()),
|
|
||||||
pending_responses: Mutex::new(HashMap::new()),
|
|
||||||
last_pending_request: Arc::new(AtomicU64::new(0)),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
|
||||||
options,
|
|
||||||
state: Arc::new(state),
|
|
||||||
shutdown: Arc::new(ShutdownEvent::new()),
|
|
||||||
is_shutdown: Arc::new(AtomicBool::new(false)),
|
|
||||||
is_started: Arc::new(AtomicBool::new(false)),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates whether the server is currently running.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_started(&self) -> bool {
|
|
||||||
self.inner.is_started.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if there is an open browser tab ready to respond to requests by
|
|
||||||
/// verifying the time since the last pending request.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_session_active(&self) -> bool {
|
|
||||||
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the signer proxy webpage URL
|
|
||||||
#[inline]
|
|
||||||
pub fn url(&self) -> String {
|
|
||||||
format!("http://{}", self.inner.options.addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the proxy server.
|
|
||||||
///
|
|
||||||
/// If this is not called explicitly, the server will be automatically
|
|
||||||
/// started on the first interaction with the signer.
|
|
||||||
pub async fn start(&self) -> Result<(), Error> {
|
|
||||||
// Ensure is not shutdown
|
|
||||||
if self.inner.is_shutdown() {
|
|
||||||
return Err(Error::shutdown());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the proxy as started and check if was already started
|
|
||||||
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Immediately return if already started
|
|
||||||
if is_started {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
|
|
||||||
Ok(listener) => listener,
|
|
||||||
Err(e) => {
|
|
||||||
// Undo the started flag if binding fails
|
|
||||||
self.inner.is_started.store(false, Ordering::SeqCst);
|
|
||||||
return Err(Error::from(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr: SocketAddr = self.inner.options.addr;
|
|
||||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
|
||||||
let custom_html: &'static str = self.inner.options.custom_html;
|
|
||||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
tracing::info!("Starting proxy server on {addr}");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Race between accepting a new connection and shutdown signal
|
|
||||||
let shutdown_listener = shutdown.listen();
|
|
||||||
|
|
||||||
enum AcceptEvent {
|
|
||||||
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = smol::future::or(
|
|
||||||
async { AcceptEvent::Connection(listener.accept().await) },
|
|
||||||
async {
|
|
||||||
shutdown_listener.await;
|
|
||||||
AcceptEvent::Shutdown
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match event {
|
|
||||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
|
||||||
let state: Arc<ProxyState> = state.clone();
|
|
||||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
let shutdown_listener = shutdown.listen();
|
|
||||||
|
|
||||||
smol::future::or(
|
|
||||||
async {
|
|
||||||
handle_connection(stream, state, custom_html).await;
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
shutdown_listener.await;
|
|
||||||
tracing::debug!(
|
|
||||||
"Closing connection, proxy server is shutting down."
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
AcceptEvent::Connection(Err(e)) => {
|
|
||||||
tracing::error!("Failed to accept connection: {e}");
|
|
||||||
}
|
|
||||||
AcceptEvent::Shutdown => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Proxy server shut down.");
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_pending_response(&self, id: Uuid, tx: channel::Sender<Result<Value, String>>) {
|
|
||||||
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
|
||||||
pending_responses.insert(id, tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_outgoing_request(&self, request: RequestData) {
|
|
||||||
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
|
||||||
outgoing_requests.push(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
// Start the proxy if not already started
|
|
||||||
self.start().await?;
|
|
||||||
|
|
||||||
// Construct the request
|
|
||||||
let request: RequestData = RequestData::new(method, params);
|
|
||||||
|
|
||||||
// Create a bounded channel of size 1 as a oneshot replacement
|
|
||||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
|
||||||
|
|
||||||
// Store the response sender
|
|
||||||
self.store_pending_response(request.id, tx).await;
|
|
||||||
|
|
||||||
// Add to outgoing requests queue
|
|
||||||
self.store_outgoing_request(request).await;
|
|
||||||
|
|
||||||
// Wait for response with timeout
|
|
||||||
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
|
||||||
Ok(Err(error)) => Err(Error::generic(error)),
|
|
||||||
Err(TimeoutError) => Err(Error::timeout()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
|
||||||
self.request(RequestMethod::GetPublicKey, json!({})).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
|
||||||
let event: Event = self
|
|
||||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
|
||||||
.await?;
|
|
||||||
event.verify()?;
|
|
||||||
Ok(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncGetPublicKey for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn get_public_key_async(
|
|
||||||
&self,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
|
|
||||||
Box::pin(async move { self._get_public_key().await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncSignEvent for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn sign_event_async(
|
|
||||||
&self,
|
|
||||||
unsigned: UnsignedEvent,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
|
|
||||||
Box::pin(async move { self._sign_event(unsigned).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncNip04 for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn nip04_encrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip04_decrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
encrypted_content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncNip44 for BrowserSignerProxy {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nip44_decrypt_async<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
payload: &'a str,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
|
|
||||||
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Handle a single HTTP connection.
|
|
||||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
|
|
||||||
// Read the request line
|
|
||||||
let mut request_line = String::new();
|
|
||||||
if reader.read_line(&mut request_line).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let request_line = request_line.trim_end().to_string();
|
|
||||||
|
|
||||||
// Parse method, path, and HTTP version from request line
|
|
||||||
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
|
||||||
if parts.len() < 2 {
|
|
||||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let method = parts[0].to_uppercase();
|
|
||||||
let path = parts[1].to_string();
|
|
||||||
|
|
||||||
// Read headers until empty line
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
let mut content_length: usize = 0;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
if reader.read_line(&mut line).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let line = line.trim_end().to_string();
|
|
||||||
if line.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(value) = line.strip_prefix("content-length:") {
|
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
|
||||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
|
||||||
content_length = value.trim().parse().unwrap_or(0);
|
|
||||||
}
|
|
||||||
headers.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
match (method.as_str(), path.as_str()) {
|
|
||||||
// Serve the HTML proxy page
|
|
||||||
("GET", "/") => {
|
|
||||||
let html = if custom_html.is_empty() {
|
|
||||||
DEFAULT_HTML
|
|
||||||
} else {
|
|
||||||
custom_html
|
|
||||||
};
|
|
||||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
|
||||||
}
|
|
||||||
// Serve the JS proxy script
|
|
||||||
("GET", "/proxy.js") => {
|
|
||||||
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
|
|
||||||
}
|
|
||||||
// Browser polls this endpoint to get pending requests
|
|
||||||
("GET", "/api/pending") => {
|
|
||||||
state
|
|
||||||
.last_pending_request
|
|
||||||
.store(current_time(), Ordering::SeqCst);
|
|
||||||
|
|
||||||
let mut outgoing = state.outgoing_requests.lock().await;
|
|
||||||
|
|
||||||
let requests = Requests::new(&outgoing);
|
|
||||||
let json = match serde_json::to_string(&requests) {
|
|
||||||
Ok(j) => j,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to serialize pending requests: {e}");
|
|
||||||
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("Sending {} pending requests to browser", requests.len());
|
|
||||||
|
|
||||||
// Clear the outgoing requests after sending them
|
|
||||||
outgoing.clear();
|
|
||||||
|
|
||||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
|
||||||
}
|
|
||||||
// Receive response from browser extension
|
|
||||||
("POST", "/api/response") => {
|
|
||||||
let mut body_bytes = vec![0u8; content_length];
|
|
||||||
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
|
|
||||||
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message: Message = match serde_json::from_slice(&body_bytes) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse response body: {e}");
|
|
||||||
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!("Received response from browser: {message:?}");
|
|
||||||
|
|
||||||
let id: Uuid = message.id;
|
|
||||||
let mut pending = state.pending_responses.lock().await;
|
|
||||||
|
|
||||||
match pending.remove(&id) {
|
|
||||||
Some(sender) => {
|
|
||||||
// Use try_send since we already hold the lock
|
|
||||||
let _ = sender.try_send(message.into_result());
|
|
||||||
tracing::info!("Forwarded response for request {id}");
|
|
||||||
}
|
|
||||||
None => tracing::warn!("No pending request found for {id}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
|
||||||
}
|
|
||||||
// CORS preflight
|
|
||||||
("OPTIONS", _) => {
|
|
||||||
let response = "HTTP/1.1 200 OK\r\n\
|
|
||||||
Access-Control-Allow-Origin: *\r\n\
|
|
||||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
|
||||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
|
||||||
Content-Length: 0\r\n\
|
|
||||||
Connection: close\r\n\
|
|
||||||
\r\n";
|
|
||||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
|
||||||
let _ = reader.get_mut().flush().await;
|
|
||||||
}
|
|
||||||
// 404 - not found
|
|
||||||
_ => {
|
|
||||||
send_response(&mut reader, 404, "Not Found", "", "").await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write an HTTP response to the stream.
|
|
||||||
async fn send_response(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
|
|
||||||
if !content_type.is_empty() {
|
|
||||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a response with CORS headers and JSON content type.
|
|
||||||
async fn send_response_cors_json(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
response.push_str("Content-Type: application/json\r\n");
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a response with CORS headers.
|
|
||||||
async fn send_response_cors(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
body: &str,
|
|
||||||
) {
|
|
||||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
|
||||||
|
|
||||||
if !content_type.is_empty() {
|
|
||||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
|
||||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
|
||||||
response.push_str("Connection: close\r\n");
|
|
||||||
response.push_str("\r\n");
|
|
||||||
response.push_str(body);
|
|
||||||
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.flush().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// An error indicating that an operation timed out.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct TimeoutError;
|
|
||||||
|
|
||||||
/// Races a channel receive against a duration.
|
|
||||||
///
|
|
||||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
|
||||||
/// elapses first or the channel is closed.
|
|
||||||
async fn race_timeout<T>(
|
|
||||||
duration: Duration,
|
|
||||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
|
||||||
) -> Result<T, TimeoutError> {
|
|
||||||
enum Event<T> {
|
|
||||||
Value(T),
|
|
||||||
ChannelClosed,
|
|
||||||
Timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = smol::future::or(
|
|
||||||
async {
|
|
||||||
match recv.await {
|
|
||||||
Ok(value) => Event::Value(value),
|
|
||||||
Err(_) => Event::ChannelClosed,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
smol::Timer::after(duration).await;
|
|
||||||
Event::Timeout
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Value(value) => Ok(value),
|
|
||||||
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Utility ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
|
||||||
/// time is before the epoch, returns 0.
|
|
||||||
#[inline]
|
|
||||||
fn current_time() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Copyright (c) 2022-2023 Yuki Kishimoto
|
|
||||||
// Copyright (c) 2023-2025 Rust Nostr Developers
|
|
||||||
// Distributed under the MIT software license
|
|
||||||
|
|
||||||
//! Prelude
|
|
||||||
|
|
||||||
#![allow(unknown_lints)]
|
|
||||||
#![allow(ambiguous_glob_reexports)]
|
|
||||||
#![doc(hidden)]
|
|
||||||
|
|
||||||
pub use nostr::prelude::*;
|
|
||||||
|
|
||||||
pub use crate::error::{Error, ErrorKind};
|
|
||||||
pub use crate::*;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
@@ -80,9 +80,6 @@ pub struct ChatRegistry {
|
|||||||
/// Chat rooms
|
/// Chat rooms
|
||||||
rooms: Vec<Entity<Room>>,
|
rooms: Vec<Entity<Room>>,
|
||||||
|
|
||||||
/// O(1) room lookup by room ID
|
|
||||||
room_index: HashMap<u64, Entity<Room>>,
|
|
||||||
|
|
||||||
/// Events that failed to unwrap for any reason
|
/// Events that failed to unwrap for any reason
|
||||||
trash: Entity<BTreeSet<FailedMessage>>,
|
trash: Entity<BTreeSet<FailedMessage>>,
|
||||||
|
|
||||||
@@ -173,7 +170,6 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
rooms: vec![],
|
rooms: vec![],
|
||||||
room_index: HashMap::new(),
|
|
||||||
trash: cx.new(|_| BTreeSet::default()),
|
trash: cx.new(|_| BTreeSet::default()),
|
||||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||||
event_map: Arc::new(RwLock::new(HashMap::default())),
|
event_map: Arc::new(RwLock::new(HashMap::default())),
|
||||||
@@ -365,8 +361,7 @@ impl ChatRegistry {
|
|||||||
.query(filter)
|
.query(filter)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.first_owned()
|
||||||
.next()
|
|
||||||
.is_some();
|
.is_some();
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
@@ -398,8 +393,7 @@ impl ChatRegistry {
|
|||||||
.database()
|
.database()
|
||||||
.query(filter)
|
.query(filter)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.first_owned()
|
||||||
.next()
|
|
||||||
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
|
||||||
|
|
||||||
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
|
||||||
@@ -442,9 +436,12 @@ impl ChatRegistry {
|
|||||||
self.tracking.load(Ordering::Acquire)
|
self.tracking.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a weak reference to a room by its ID
|
/// Get a weak reference to a room by its ID.
|
||||||
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
|
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||||
self.room_index.get(id).map(|room| room.downgrade())
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.find(|this| &this.read(cx).id == id)
|
||||||
|
.map(|this| this.downgrade())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all rooms based on the filter.
|
/// Get all rooms based on the filter.
|
||||||
@@ -514,11 +511,7 @@ impl ChatRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let room: Room = room.into().organize(&public_key);
|
let room: Room = room.into().organize(&public_key);
|
||||||
let room_id = room.id;
|
self.rooms.insert(0, cx.new(|_| room));
|
||||||
let entity = cx.new(|_| room);
|
|
||||||
|
|
||||||
self.room_index.insert(room_id, entity.clone());
|
|
||||||
self.rooms.insert(0, entity);
|
|
||||||
|
|
||||||
cx.emit(ChatEvent::Ping);
|
cx.emit(ChatEvent::Ping);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -531,11 +524,9 @@ impl ChatRegistry {
|
|||||||
// Get the room's ID.
|
// Get the room's ID.
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
// If the room is new, add it to the registry and index.
|
// If the room is new, add it to the registry.
|
||||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
let entity = room.to_owned();
|
self.rooms.insert(0, room.to_owned());
|
||||||
e.insert(entity.clone());
|
|
||||||
self.rooms.insert(0, entity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the open room event deferred to avoid re-entrant reads
|
// Emit the open room event deferred to avoid re-entrant reads
|
||||||
@@ -546,24 +537,18 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Close a room.
|
/// Close a room.
|
||||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.room_index.contains_key(&id) {
|
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
self.room_index.remove(&id);
|
|
||||||
self.rooms.retain(|r| r.read(cx).id != id);
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
cx.defer_in(window, move |_this, _window, cx| {
|
||||||
cx.emit(ChatEvent::CloseRoom(id));
|
cx.emit(ChatEvent::CloseRoom(id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort rooms by their created at. Only notifies if order changed.
|
/// Sort rooms by their created at.
|
||||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||||
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
|
||||||
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||||
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
|
|
||||||
if before != after {
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Finding rooms based on a query.
|
/// Finding rooms based on a query.
|
||||||
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||||
@@ -589,7 +574,6 @@ impl ChatRegistry {
|
|||||||
/// Reset the registry.
|
/// Reset the registry.
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.rooms.clear();
|
self.rooms.clear();
|
||||||
self.room_index.clear();
|
|
||||||
self.trash.update(cx, |this, cx| {
|
self.trash.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -617,9 +601,7 @@ impl ChatRegistry {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let new_room_id = new_room.id;
|
let new_room_id = new_room.id;
|
||||||
let entity = cx.new(|_| new_room);
|
self.rooms.push(cx.new(|_| new_room));
|
||||||
self.room_index.insert(new_room_id, entity.clone());
|
|
||||||
self.rooms.push(entity);
|
|
||||||
|
|
||||||
let new_index = self.rooms.len();
|
let new_index = self.rooms.len();
|
||||||
room_map.insert(new_room_id, new_index);
|
room_map.insert(new_room_id, new_index);
|
||||||
@@ -629,7 +611,13 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Load all rooms from the database.
|
/// Load all rooms from the database.
|
||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_task(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let task = self.get_rooms_from_database(public_key, cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
match task.await {
|
||||||
@@ -651,64 +639,61 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Create a task to load rooms from the database
|
||||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
fn get_rooms_from_database(
|
||||||
|
&self,
|
||||||
|
public_key: PublicKey,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let public_key = signer.get_public_key_async().await?;
|
let contacts = client
|
||||||
|
|
||||||
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contacts: HashSet<PublicKey> = client
|
|
||||||
.database()
|
.database()
|
||||||
.query(filter)
|
.contacts_public_keys(public_key)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Query all cached rumor events (works with both old and new cache formats)
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let mut rooms: HashSet<Room> = HashSet::new();
|
||||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
for raw in events.into_iter() {
|
for raw in events.into_iter() {
|
||||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
|
||||||
&& rumor.tags.public_keys().next().is_some()
|
&& rumor.tags.public_keys().peekable().peek().is_some()
|
||||||
{
|
{
|
||||||
if rumor.pubkey != public_key
|
|
||||||
&& !rumor.tags.public_keys().any(|k| k == public_key)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rooms = HashSet::with_capacity(grouped.len());
|
for (_id, mut messages) in grouped.into_iter() {
|
||||||
|
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||||
|
|
||||||
for (_id, messages) in grouped.into_iter() {
|
// Always use the latest message
|
||||||
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
|
let Some(latest) = messages.first() else {
|
||||||
let room = Room::from(latest).organize(&public_key);
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct the room from the latest message.
|
||||||
|
//
|
||||||
|
// Call `.organize` to ensure the current user is at the end of the list.
|
||||||
|
let mut room = Room::from(latest).organize(&public_key);
|
||||||
|
|
||||||
|
// Check if the user has responded to the room
|
||||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||||
|
|
||||||
|
// Check if public keys are from the user's contacts
|
||||||
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||||
|
|
||||||
let room = if user_sent || is_contact {
|
// Set the room's kind based on status
|
||||||
room.kind(RoomKind::Ongoing)
|
if user_sent || is_contact {
|
||||||
} else {
|
room = room.kind(RoomKind::Ongoing);
|
||||||
room
|
}
|
||||||
};
|
|
||||||
|
|
||||||
rooms.insert(room);
|
rooms.insert(room);
|
||||||
}
|
}
|
||||||
@@ -728,7 +713,7 @@ impl ChatRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.room_index.get(&message.room).cloned() {
|
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||||
Some(room) => {
|
Some(room) => {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||||
@@ -840,7 +825,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
|||||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||||
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
let filter = Filter::new().identifier(gift_wrap).limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Event is not cached yet."))
|
Err(anyhow!("Event is not cached yet."))
|
||||||
|
|||||||
@@ -271,8 +271,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> &[PublicKey] {
|
pub fn members(&self) -> Vec<PublicKey> {
|
||||||
&self.members
|
self.members.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// Checks if the room has more than two members (group)
|
||||||
@@ -356,7 +356,7 @@ impl Room {
|
|||||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let members = self.members().to_vec();
|
let members = self.members();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
@@ -403,7 +403,7 @@ impl Room {
|
|||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
|
||||||
|
|
||||||
let messages = client
|
let messages = client
|
||||||
.database()
|
.database()
|
||||||
@@ -461,10 +461,13 @@ impl Room {
|
|||||||
// Add all receiver tags (no intermediate allocation)
|
// Add all receiver tags (no intermediate allocation)
|
||||||
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
|
||||||
let member = persons.read(cx).get(public_key, cx);
|
let member = persons.read(cx).get(public_key, cx);
|
||||||
tags.push(Tag::from(Nip01Tag::PublicKey {
|
tags.push(
|
||||||
|
Nip01Tag::PublicKey {
|
||||||
public_key: member.public_key(),
|
public_key: member.public_key(),
|
||||||
relay_hint: member.messaging_relay_hint(),
|
relay_hint: member.messaging_relay_hint(),
|
||||||
}));
|
}
|
||||||
|
.to_tag(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message rumor event
|
// Construct a direct message rumor event
|
||||||
|
|||||||
@@ -25,5 +25,4 @@ serde.workspace = true
|
|||||||
|
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
pulldown-cmark = "0.13.1"
|
pulldown-cmark = "0.13.1"
|
||||||
regex = "1"
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::{Context as AnyhowContext, Error};
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
@@ -11,14 +11,12 @@ use gpui::{
|
|||||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
||||||
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
|
||||||
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
|
img, list, px, red, relative, svg, white,
|
||||||
white,
|
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use regex::Regex;
|
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use state::{NostrRegistry, upload};
|
||||||
@@ -40,11 +38,6 @@ use crate::text::RenderedText;
|
|||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||||
|
|
||||||
/// Regex matching strings that consist entirely of emoji characters,
|
|
||||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
|
||||||
static EMOJI_RE: LazyLock<Regex> =
|
|
||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
@@ -66,9 +59,6 @@ pub struct ChatPanel {
|
|||||||
/// All messages (sorted by created_at)
|
/// All messages (sorted by created_at)
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
|
||||||
/// O(1) message lookup by EventId
|
|
||||||
message_index: HashMap<EventId, usize>,
|
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
||||||
|
|
||||||
@@ -176,7 +166,6 @@ impl ChatPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
message_index: HashMap::new(),
|
|
||||||
reactions: BTreeMap::new(),
|
reactions: BTreeMap::new(),
|
||||||
room,
|
room,
|
||||||
list_state,
|
list_state,
|
||||||
@@ -242,29 +231,23 @@ impl ChatPanel {
|
|||||||
while let Ok(status) = rx.recv_async().await {
|
while let Ok(status) = rx.recv_async().await {
|
||||||
{
|
{
|
||||||
let mut map = reports.write().unwrap();
|
let mut map = reports.write().unwrap();
|
||||||
let status_id = match &*status {
|
for reports in map.values_mut() {
|
||||||
SendStatus::Ok { id, .. } => *id,
|
for report in reports.iter_mut() {
|
||||||
SendStatus::Failed { id, .. } => *id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find the matching report and update it (exit early on first match)
|
|
||||||
'outer: for reports_list in map.values_mut() {
|
|
||||||
for report in reports_list.iter_mut() {
|
|
||||||
let Some(output) = report.output.as_mut() else {
|
let Some(output) = report.output.as_mut() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if *output.id() != status_id {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match &*status {
|
match &*status {
|
||||||
SendStatus::Ok { relay, .. } => {
|
SendStatus::Ok { id, relay } => {
|
||||||
|
if output.id() == id {
|
||||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||||
}
|
}
|
||||||
SendStatus::Failed { relay, message, .. } => {
|
}
|
||||||
|
SendStatus::Failed { id, relay, message } => {
|
||||||
|
if output.id() == id {
|
||||||
output.failed.insert(relay.clone(), message.clone());
|
output.failed.insert(relay.clone(), message.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break 'outer;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,10 +259,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
/// Subscribe to room events
|
/// Subscribe to room events
|
||||||
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(room) = self.room.upgrade() else {
|
if let Some(room) = self.room.upgrade() {
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.subscriptions.push(cx.subscribe_in(
|
self.subscriptions.push(cx.subscribe_in(
|
||||||
&room,
|
&room,
|
||||||
window,
|
window,
|
||||||
@@ -290,18 +270,6 @@ impl ChatPanel {
|
|||||||
this.insert_reaction(&message.rumor, cx);
|
this.insert_reaction(&message.rumor, cx);
|
||||||
} else {
|
} else {
|
||||||
this.insert_message(message, false, cx);
|
this.insert_message(message, false, cx);
|
||||||
|
|
||||||
if !window.is_window_active() {
|
|
||||||
cx.show_system_notification(SystemNotification {
|
|
||||||
tag: "message".into(),
|
|
||||||
title: "New Message".into(),
|
|
||||||
body: "You have a new message.".into(),
|
|
||||||
actions: vec![SystemNotificationAction {
|
|
||||||
id: "open".into(),
|
|
||||||
label: "Open".into(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoomEvent::Reload => {
|
RoomEvent::Reload => {
|
||||||
@@ -315,6 +283,7 @@ impl ChatPanel {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -382,17 +351,6 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If replying to exactly one message with only a valid emoji,
|
|
||||||
// send as a reaction instead of a text message
|
|
||||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
|
||||||
{
|
|
||||||
for reply in &replies {
|
|
||||||
self.send_reaction(&content, reply, window, cx);
|
|
||||||
}
|
|
||||||
self.clear(window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send_message(&content, replies, false, window, cx);
|
self.send_message(&content, replies, false, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,10 +472,6 @@ impl ChatPanel {
|
|||||||
|
|
||||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||||
self.messages.insert(pos, msg);
|
self.messages.insert(pos, msg);
|
||||||
// Rebuild message index after insertion (indices from pos to end shift)
|
|
||||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
|
||||||
self.message_index.insert(message.id, i);
|
|
||||||
}
|
|
||||||
self.list_state.splice(old_len..old_len, 1);
|
self.list_state.splice(old_len..old_len, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
@@ -560,25 +514,22 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reports
|
/// Check if a message has any reports
|
||||||
fn has_reports(&self, id: &EventId) -> bool {
|
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
||||||
self.reports_by_id.read().unwrap().contains_key(id)
|
self.reports_by_id.read().unwrap().get(id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone reports for a message (used for modal display, not called during render)
|
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
|
||||||
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
|
|
||||||
self.reports_by_id.read().unwrap().get(id).cloned()
|
self.reports_by_id.read().unwrap().get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a message by its ID (O(1) lookup)
|
/// Get a message by its ID
|
||||||
fn message(&self, id: &EventId) -> Option<&Message> {
|
fn message(&self, id: &EventId) -> Option<&Message> {
|
||||||
self.message_index
|
self.messages.iter().find(|msg| &msg.id == id)
|
||||||
.get(id)
|
|
||||||
.and_then(|&ix| self.messages.get(ix))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reaction by its target ID (returns reference, no allocation)
|
/// Get a reaction by its target ID
|
||||||
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
|
||||||
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
self.reactions.get(id).cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reactions
|
/// Check if a message has any reactions
|
||||||
@@ -961,7 +912,7 @@ impl ChatPanel {
|
|||||||
let replies = message.replies_to.as_slice();
|
let replies = message.replies_to.as_slice();
|
||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
let has_reactions = self.has_reaction(&id);
|
let has_reactions = self.has_reaction(&id);
|
||||||
let has_reports = self.has_reports(&id);
|
let has_reports = self.has_reports(&id, cx);
|
||||||
|
|
||||||
// Hide avatar setting
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -1144,7 +1095,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Group reactions by emoji and collect authors for each
|
// Group reactions by emoji and collect authors for each
|
||||||
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
||||||
for (emoji, author) in reactions {
|
for (emoji, author) in &reactions {
|
||||||
grouped.entry(emoji.clone()).or_default().push(*author);
|
grouped.entry(emoji.clone()).or_default().push(*author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1176,7 +1127,7 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
||||||
let reports = self.sent_reports(id);
|
let reports = self.sent_reports(id, cx);
|
||||||
|
|
||||||
let pending = reports
|
let pending = reports
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ use std::path::PathBuf;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -414,7 +414,7 @@ impl DeviceRegistry {
|
|||||||
.pubkey(app_pubkey)
|
.pubkey(app_pubkey)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
match client.database().query(filter).await?.first_owned() {
|
||||||
// Found an approval event
|
// Found an approval event
|
||||||
Some(event) => Ok(Some(event)),
|
Some(event) => Ok(Some(event)),
|
||||||
// No approval event found, construct a request event
|
// No approval event found, construct a request event
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ anyhow.workspace = true
|
|||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use common::EventExt;
|
use common::EventExt;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use gpui::SharedString;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::Announcement;
|
use state::Announcement;
|
||||||
|
|
||||||
|
const IMAGE_RESIZER: &str = "https://wsrv.nl";
|
||||||
|
|
||||||
/// Person
|
/// Person
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
@@ -109,7 +111,13 @@ impl Person {
|
|||||||
.picture
|
.picture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|picture| !picture.is_empty())
|
.filter(|picture| !picture.is_empty())
|
||||||
.map(|picture| picture.into())
|
.map(|picture| {
|
||||||
|
let encoded_picture = urlencoding::encode(picture);
|
||||||
|
let url = format!(
|
||||||
|
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
|
||||||
|
);
|
||||||
|
url.into()
|
||||||
|
})
|
||||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ mime_guess = "2.0.4"
|
|||||||
nostr-memory.workspace = true
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
|
||||||
nostr-lmdb.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
gpui_tokio.workspace = true
|
gpui_tokio.workspace = true
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use browser_signer_proxy::prelude::*;
|
|
||||||
use common::config_dir;
|
use common::config_dir;
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
@@ -27,7 +24,7 @@ pub use nip4e::*;
|
|||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default
|
// rustls uses the `aws_lc_rs` provider by default
|
||||||
// This only errors if the default provider has already
|
// This only errors if the default provider has already
|
||||||
// been installed. We can ignore this `Result`.
|
// been installed. We can ignore this `Result`.
|
||||||
@@ -40,7 +37,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
gpui_tokio::init(cx);
|
gpui_tokio::init(cx);
|
||||||
|
|
||||||
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
|
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||||
@@ -61,16 +58,16 @@ pub enum StateEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StateEvent {
|
impl StateEvent {
|
||||||
pub fn signer_changed(&self) -> bool {
|
|
||||||
matches!(self, StateEvent::SignerChanged)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error<T>(error: T) -> Self
|
pub fn error<T>(error: T) -> Self
|
||||||
where
|
where
|
||||||
T: Into<String>,
|
T: Into<String>,
|
||||||
{
|
{
|
||||||
Self::Error(error.into())
|
Self::Error(error.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn signer_changed(&self) -> bool {
|
||||||
|
matches!(self, StateEvent::SignerChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nostr Registry
|
/// Nostr Registry
|
||||||
@@ -103,7 +100,7 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new nostr instance
|
/// Create a new nostr instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let authenticator = SignerAuthenticator::new(signer.clone());
|
let authenticator = SignerAuthenticator::new(signer.clone());
|
||||||
|
|
||||||
@@ -136,10 +133,6 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.get_user_credential(cx);
|
||||||
}
|
}
|
||||||
@@ -265,11 +258,6 @@ impl NostrRegistry {
|
|||||||
this.set_signer(signer, cx);
|
this.set_signer(signer, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
} else if content == "proxy" {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.connect_proxy(cx);
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -315,81 +303,6 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the browser proxy
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
|
|
||||||
let (tx, rx) = flume::bounded::<String>(1);
|
|
||||||
|
|
||||||
self.tasks.push(Tokio::spawn_result(cx, {
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move {
|
|
||||||
// Start the proxy and get the web url
|
|
||||||
proxy.start().await?;
|
|
||||||
// Notify GPUI
|
|
||||||
let url = proxy.url();
|
|
||||||
tx.send(url).ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.tasks.push(Tokio::spawn_result(cx, {
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move {
|
|
||||||
loop {
|
|
||||||
if proxy.is_session_active() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
smol::Timer::after(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn({
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
async move |this, cx| {
|
|
||||||
while let Ok(url) = rx.recv_async().await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
|
|
||||||
cx.background_spawn(async move { save.await.ok() }).detach();
|
|
||||||
cx.open_url(&url);
|
|
||||||
this.set_signer(proxy.clone(), cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Monitor the session, if the browser disconnects, notify user to reconnect
|
|
||||||
self.tasks.push(cx.spawn({
|
|
||||||
let proxy = proxy.clone();
|
|
||||||
let executor = cx.background_executor().clone();
|
|
||||||
async move |this, cx| {
|
|
||||||
// Wait for the signer to be confirmed (timeout is 30s)
|
|
||||||
executor.timer(Duration::from_secs(30)).await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
executor.timer(Duration::from_secs(5)).await;
|
|
||||||
if !proxy.is_session_active() {
|
|
||||||
_ = this.update(cx, |this, cx| {
|
|
||||||
// Only notify if this proxy is still the active signer
|
|
||||||
if this.current_user.is_some() {
|
|
||||||
this.signer.swap_inner(Keys::generate());
|
|
||||||
this.current_user = None;
|
|
||||||
cx.emit(StateEvent::NoSigner);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the public key of a NIP-05 address
|
/// Get the public key of a NIP-05 address
|
||||||
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||||
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||||
Window, div, img, px,
|
px,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
|||||||
/// ```
|
/// ```
|
||||||
/// use ui::{Avatar};
|
/// use ui::{Avatar};
|
||||||
///
|
///
|
||||||
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
|
/// Avatar::new("path/to/image.png")
|
||||||
|
/// .grayscale(true)
|
||||||
|
/// .border_color(gpui::red());
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
@@ -128,7 +130,7 @@ impl RenderOnce for Avatar {
|
|||||||
self.image
|
self.image
|
||||||
.size(image_size)
|
.size(image_size)
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.object_fit(ObjectFit::Cover)
|
.object_fit(gpui::ObjectFit::Fill)
|
||||||
.bg(cx.theme().ghost_element_background)
|
.bg(cx.theme().ghost_element_background)
|
||||||
.with_fallback(move || {
|
.with_fallback(move || {
|
||||||
img("brand/avatar.png")
|
img("brand/avatar.png")
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl Render for DragPanel {
|
|||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text)
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||||
@@ -312,7 +312,6 @@ impl TabPanel {
|
|||||||
|
|
||||||
cx.emit(PanelEvent::ZoomOut);
|
cx.emit(PanelEvent::ZoomOut);
|
||||||
cx.emit(PanelEvent::LayoutChanged);
|
cx.emit(PanelEvent::LayoutChanged);
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detach_panel(
|
fn detach_panel(
|
||||||
@@ -322,22 +321,10 @@ impl TabPanel {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let panel_view = panel.view();
|
let panel_view = panel.view();
|
||||||
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
|
|
||||||
self.panels.retain(|p| p.view() != panel_view);
|
self.panels.retain(|p| p.view() != panel_view);
|
||||||
|
|
||||||
if self.active_ix >= self.panels.len() {
|
if self.active_ix >= self.panels.len() {
|
||||||
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
|
||||||
} else if let Some(removed_ix) = removed_ix {
|
|
||||||
if removed_ix < self.active_ix {
|
|
||||||
self.active_ix = self.active_ix.saturating_sub(1);
|
|
||||||
} else if removed_ix == self.active_ix {
|
|
||||||
// The active panel was removed and another panel shifted into
|
|
||||||
// its position. Activate the new panel at the same index.
|
|
||||||
if let Some(new_active) = self.panels.get(self.active_ix) {
|
|
||||||
new_active.set_active(true, cx);
|
|
||||||
}
|
|
||||||
self.focus_active_panel(window, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +613,7 @@ impl TabPanel {
|
|||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.child(panel.title(cx)),
|
.child(panel.title(cx)),
|
||||||
)
|
)
|
||||||
.when(state.draggable, |this| {
|
.when(state.draggable, |this| {
|
||||||
@@ -700,8 +687,8 @@ impl TabPanel {
|
|||||||
.on_click(cx.listener({
|
.on_click(cx.listener({
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
move |view, _ev, window, cx| {
|
move |view, _ev, window, cx| {
|
||||||
cx.stop_propagation();
|
|
||||||
view.remove_panel(&panel, window, cx);
|
view.remove_panel(&panel, window, cx);
|
||||||
|
view.set_active_ix(ix, window, cx);
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use instant::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -6,7 +7,6 @@ use gpui::{
|
|||||||
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||||
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm};
|
use crate::actions::{Cancel, Confirm};
|
||||||
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
|
|||||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||||
let x = bounds.center().x - self.width / 2.;
|
let x = bounds.center().x - self.width / 2.;
|
||||||
|
|
||||||
let mut padding_right = px(16.);
|
let mut padding_right = px(8.);
|
||||||
let mut padding_left = px(16.);
|
let mut padding_left = px(8.);
|
||||||
|
|
||||||
if let Some(pl) = self.style.padding.left {
|
if let Some(pl) = self.style.padding.left {
|
||||||
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
|
||||||
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
|
|||||||
.when_some(self.max_width, |this, w| this.max_w(w))
|
.when_some(self.max_width, |this, w| this.max_w(w))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_2()
|
||||||
.h_8()
|
.h_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
|
|||||||
|
|
||||||
/// Returns a `Div` as divider.
|
/// Returns a `Div` as divider.
|
||||||
pub fn divider(cx: &App) -> Div {
|
pub fn divider(cx: &App) -> Div {
|
||||||
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
|
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! font_weight {
|
macro_rules! font_weight {
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ impl RenderOnce for Tab {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.h(TABBAR_HEIGHT)
|
.h(TABBAR_HEIGHT)
|
||||||
.relative()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.text_color(fg)
|
.text_color(fg)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.when(!self.selected && !self.disabled, |this| {
|
||||||
|
this.hover(|this| this.text_color(cx.theme().secondary_foreground))
|
||||||
|
})
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -220,21 +222,5 @@ impl RenderOnce for Tab {
|
|||||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.bottom_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.h_0p5()
|
|
||||||
.when(self.selected && !self.disabled, |this| {
|
|
||||||
this.bg(cx.theme().element_active)
|
|
||||||
})
|
|
||||||
.when(!self.selected && !self.disabled, |this| {
|
|
||||||
this.invisible().group_hover("", |this| {
|
|
||||||
this.visible().bg(cx.theme().secondary_background)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ chat = { path = "../chat" }
|
|||||||
chat_ui = { path = "../chat_ui" }
|
chat_ui = { path = "../chat_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
auto_update = { path = "../auto_update" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
browser-signer-proxy = { path = "../browser-signer-proxy" }
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
|||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
|
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportIdentity {
|
pub struct ImportIdentity {
|
||||||
@@ -164,14 +164,6 @@ impl ImportIdentity {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn proxy(&mut self, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.connect_proxy(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
self.loading = status;
|
self.loading = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -207,13 +199,10 @@ impl ImportIdentity {
|
|||||||
|
|
||||||
impl Render for ImportIdentity {
|
impl Render for ImportIdentity {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
|
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
||||||
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
|
|
||||||
|
|
||||||
let is_wasm = cfg!(target_arch = "wasm32");
|
|
||||||
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
|
||||||
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
|
||||||
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -238,20 +227,13 @@ impl Render for ImportIdentity {
|
|||||||
.child(Input::new(&self.pass_input)),
|
.child(Input::new(&self.pass_input)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(bunker_warning, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_warning)
|
|
||||||
.child(div().child(BUNKER_WARN)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(key_warning, |this| {
|
.when(key_warning, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_warning)
|
.text_color(cx.theme().text_warning)
|
||||||
.child(div().child(KEY_WARN)),
|
.child(div().font_semibold().child("Warning"))
|
||||||
|
.child(div().child(MSG)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -266,19 +248,6 @@ impl Render for ImportIdentity {
|
|||||||
this.login(window, cx);
|
this.login(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(divider(cx))
|
|
||||||
.when(!is_wasm, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("proxy")
|
|
||||||
.label("Connect via Web Extension (Experimental)")
|
|
||||||
.ghost_alt()
|
|
||||||
.loading(self.loading)
|
|
||||||
.disabled(self.loading)
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.proxy(cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||||
@@ -84,20 +84,8 @@ impl Screening {
|
|||||||
|
|
||||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||||
// Check if user is in contact list
|
// Check if user is in contact list
|
||||||
let filter = Filter::new()
|
let contacts = client.database().contacts_public_keys(current_user).await;
|
||||||
.author(current_user)
|
let followed = contacts.unwrap_or_default().contains(&public_key);
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let followed = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().any(|k| k == public_key))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(followed)
|
Ok(followed)
|
||||||
});
|
});
|
||||||
@@ -240,10 +228,11 @@ impl Screening {
|
|||||||
let public_key = self.public_key;
|
let public_key = self.public_key;
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let tag = Tag::from(Nip56Tag::PublicKey {
|
let tag = Nip56Tag::PublicKey {
|
||||||
public_key,
|
public_key,
|
||||||
report: Report::Impersonation,
|
report: Report::Impersonation,
|
||||||
});
|
}
|
||||||
|
.to_tag();
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::Reporting, "")
|
let event = EventBuilder::new(Kind::Reporting, "")
|
||||||
.tag(tag)
|
.tag(tag)
|
||||||
@@ -274,7 +263,7 @@ impl Screening {
|
|||||||
let contacts = contacts.clone();
|
let contacts = contacts.clone();
|
||||||
let total = contacts.len();
|
let total = contacts.len();
|
||||||
|
|
||||||
this.title("Mutual contacts").child(
|
this.title(SharedString::from("Mutual contacts")).child(
|
||||||
v_flex().gap_1().pb_2().child(
|
v_flex().gap_1().pb_2().child(
|
||||||
uniform_list("contacts", total, move |range, _window, cx| {
|
uniform_list("contacts", total, move |range, _window, cx| {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -353,7 +342,7 @@ impl Render for Screening {
|
|||||||
.h_7()
|
.h_7()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().surface_background)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.truncate()
|
.truncate()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
@@ -366,8 +355,7 @@ impl Render for Screening {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
Button::new("njump")
|
Button::new("njump")
|
||||||
.icon(IconName::Link)
|
.label("View on njump.me")
|
||||||
.label("njump.me")
|
|
||||||
.secondary()
|
.secondary()
|
||||||
.small()
|
.small()
|
||||||
.rounded()
|
.rounded()
|
||||||
@@ -398,7 +386,10 @@ impl Render for Screening {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.child(status_badge(Some(self.followed), cx))
|
.child(status_badge(Some(self.followed), cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex().text_sm().child("Contact").child(
|
v_flex()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Contact"))
|
||||||
|
.child(
|
||||||
div()
|
div()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
@@ -424,7 +415,7 @@ impl Render for Screening {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.child("Activity on Public Relays")
|
.child(SharedString::from("Activity on Public Relays"))
|
||||||
.child(
|
.child(
|
||||||
Button::new("active")
|
Button::new("active")
|
||||||
.icon(IconName::Info)
|
.icon(IconName::Info)
|
||||||
@@ -493,8 +484,25 @@ impl Render for Screening {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.child(status_badge(Some(mutuals > 0), cx))
|
.child(status_badge(Some(mutuals > 0), cx))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
v_flex()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.child(SharedString::from("Mutual contacts"))
|
||||||
|
.child(
|
||||||
|
Button::new("mutuals")
|
||||||
|
.icon(IconName::Info)
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.rounded()
|
||||||
|
.on_click(cx.listener(
|
||||||
|
move |this, _, window, cx| {
|
||||||
|
this.mutual_contacts(window, cx);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
@@ -506,17 +514,6 @@ impl Render for Screening {
|
|||||||
SharedString::from(NO_MUTUAL)
|
SharedString::from(NO_MUTUAL)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("mutuals")
|
|
||||||
.icon(IconName::Info)
|
|
||||||
.xsmall()
|
|
||||||
.ghost()
|
|
||||||
.rounded()
|
|
||||||
.disabled(mutuals == 0)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.mutual_contacts(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use ::settings::AppSettings;
|
use ::settings::AppSettings;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use auto_update::AutoUpdater;
|
|
||||||
use chat::{ChatEvent, ChatRegistry};
|
use chat::{ChatEvent, ChatRegistry};
|
||||||
use common::{CoopImageCache, download_dir};
|
use common::{CoopImageCache, download_dir};
|
||||||
use device::{DeviceEvent, DeviceRegistry};
|
use device::{DeviceEvent, DeviceRegistry};
|
||||||
@@ -45,12 +44,13 @@ struct MsgRelayNotification;
|
|||||||
#[action(namespace = workspace, no_json)]
|
#[action(namespace = workspace, no_json)]
|
||||||
enum Command {
|
enum Command {
|
||||||
ToggleTheme,
|
ToggleTheme,
|
||||||
Update,
|
|
||||||
RefreshMessagingRelays,
|
RefreshMessagingRelays,
|
||||||
BackupEncryption,
|
BackupEncryption,
|
||||||
ImportEncryption,
|
ImportEncryption,
|
||||||
RefreshEncryption,
|
RefreshEncryption,
|
||||||
ResetEncryption,
|
ResetEncryption,
|
||||||
|
|
||||||
ShowRelayList,
|
ShowRelayList,
|
||||||
ShowMessaging,
|
ShowMessaging,
|
||||||
ShowProfile,
|
ShowProfile,
|
||||||
@@ -379,17 +379,6 @@ impl Workspace {
|
|||||||
Command::ImportEncryption => {
|
Command::ImportEncryption => {
|
||||||
self.import_encryption(window, cx);
|
self.import_encryption(window, cx);
|
||||||
}
|
}
|
||||||
Command::Update => {
|
|
||||||
// No-op on managed distribution channels (Flatpak/Snap) where
|
|
||||||
// the in-app updater is never initialized.
|
|
||||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
|
||||||
auto_updater.update(cx, |this, cx| {
|
|
||||||
this.updater.update(cx, |updater, cx| {
|
|
||||||
updater.check(cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +551,7 @@ impl Workspace {
|
|||||||
.caret()
|
.caret()
|
||||||
.compact()
|
.compact()
|
||||||
.transparent()
|
.transparent()
|
||||||
.dropdown_menu(move |this, _window, cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
let avatar = avatar.clone();
|
let avatar = avatar.clone();
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
|
|
||||||
@@ -596,15 +585,7 @@ impl Workspace {
|
|||||||
IconName::Sun,
|
IconName::Sun,
|
||||||
Box::new(Command::ToggleTheme),
|
Box::new(Command::ToggleTheme),
|
||||||
)
|
)
|
||||||
// Only offer in-app updates when auto-update is
|
.separator()
|
||||||
// enabled (managed channels update themselves).
|
|
||||||
.when(AutoUpdater::is_available(cx), |this| {
|
|
||||||
this.separator().menu_with_icon(
|
|
||||||
"Check for Updates",
|
|
||||||
IconName::Device,
|
|
||||||
Box::new(Command::Update),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.menu_with_icon(
|
.menu_with_icon(
|
||||||
"Settings",
|
"Settings",
|
||||||
IconName::Settings,
|
IconName::Settings,
|
||||||
@@ -628,20 +609,9 @@ impl Workspace {
|
|||||||
let profile = persons.read(cx).get(&public_key, cx);
|
let profile = persons.read(cx).get(&public_key, cx);
|
||||||
let announcement = profile.announcement();
|
let announcement = profile.announcement();
|
||||||
|
|
||||||
// Update status is only shown when auto-update is available. On
|
|
||||||
// managed distribution channels (Flatpak/Snap) no updater exists, so
|
|
||||||
// nothing is rendered.
|
|
||||||
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
|
|
||||||
let updater = updater.read(cx);
|
|
||||||
(!updater.idle(cx)).then(|| updater.status(cx))
|
|
||||||
});
|
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(updater_status, |this, status| {
|
|
||||||
this.child(div().text_xs().italic().child(status))
|
|
||||||
})
|
|
||||||
.when(nip4e_enabled, |this| {
|
.when(nip4e_enabled, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
|
|||||||
@@ -88,20 +88,7 @@ impl ContactListPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contact_list: HashSet<PublicKey> = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(contact_list)
|
Ok(contact_list)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
|
|||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip17::extract_relay_list(&event).collect())
|
Ok(nip17::extract_relay_list(&event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
|
|||||||
let tags: Vec<Tag> = self
|
let tags: Vec<Tag> = self
|
||||||
.relays
|
.relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
|
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Set updating state
|
// Set updating state
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ impl RelayListPanel {
|
|||||||
.author(public_key)
|
.author(public_key)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
Ok(nip65::extract_relay_list(&event).collect())
|
Ok(nip65::extract_relay_list(&event).collect())
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("Not found."))
|
Err(anyhow!("Not found."))
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ impl RenderOnce for RoomEntry {
|
|||||||
)
|
)
|
||||||
.on_cancel(move |_event, window, cx| {
|
.on_cancel(move |_event, window, cx| {
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||||
true
|
// Prevent closing the modal on click
|
||||||
|
// modal will be automatically closed after closing panel
|
||||||
|
false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,20 +158,7 @@ impl Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let contacts: HashSet<PublicKey> = client
|
|
||||||
.database()
|
|
||||||
.query(filter)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.map(|event| event.tags.public_keys().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(contacts)
|
Ok(contacts)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,14 +354,6 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
self.new_requests = false;
|
self.new_requests = false;
|
||||||
|
|
||||||
// Reset search state when switching to inbox/requests
|
|
||||||
self.reset(window, cx);
|
|
||||||
|
|
||||||
// Clear the find input value
|
|
||||||
self.find_input.update(cx, |this, cx| {
|
|
||||||
this.set_value("", window, cx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list_items(
|
fn render_list_items(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "1.0.1"
|
version = "1.0.0-beta5"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -48,4 +48,3 @@ reqwest_client.workspace = true
|
|||||||
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
nostr-sdk.workspace = true
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# Snaps built by snapcraft without Snap Store credentials are unsigned and
|
|
||||||
# cannot be installed without bypassing signature checks. Use:
|
|
||||||
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
|
|
||||||
# For signed installs (`snap install coop`), publish via the Snap Store.
|
|
||||||
name: coop
|
name: coop
|
||||||
title: Coop
|
title: Coop
|
||||||
base: core24
|
base: core24
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use gpui::{
|
|||||||
actions, point, px, size,
|
actions, point, px, size,
|
||||||
};
|
};
|
||||||
use gpui_platform::application;
|
use gpui_platform::application;
|
||||||
use nostr_sdk::prelude::SecretKey;
|
|
||||||
use state::{APP_ID, CLIENT_NAME};
|
use state::{APP_ID, CLIENT_NAME};
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
@@ -17,14 +16,6 @@ fn main() {
|
|||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// Parse CLI arguments for --sec <nsec1>
|
|
||||||
let cli_key = parse_cli_key();
|
|
||||||
if let Err(ref e) = cli_key {
|
|
||||||
eprintln!("Failed to parse --sec argument: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
let cli_key = cli_key.unwrap();
|
|
||||||
|
|
||||||
// Run application
|
// Run application
|
||||||
application()
|
application()
|
||||||
.with_assets(Assets)
|
.with_assets(Assets)
|
||||||
@@ -33,9 +24,6 @@ fn main() {
|
|||||||
// Load embedded fonts in assets/fonts
|
// Load embedded fonts in assets/fonts
|
||||||
load_embedded_fonts(cx);
|
load_embedded_fonts(cx);
|
||||||
|
|
||||||
// Set app identity
|
|
||||||
cx.set_app_identity(APP_ID, CLIENT_NAME);
|
|
||||||
|
|
||||||
// Register the `quit` function
|
// Register the `quit` function
|
||||||
cx.on_action(quit);
|
cx.on_action(quit);
|
||||||
|
|
||||||
@@ -84,7 +72,7 @@ fn main() {
|
|||||||
settings::init(window, cx);
|
settings::init(window, cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(window, cx, cli_key);
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(window, cx);
|
person::init(window, cx);
|
||||||
@@ -134,25 +122,6 @@ fn load_embedded_fonts(cx: &App) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
let mut i = 0;
|
|
||||||
while i < args.len() {
|
|
||||||
if args[i] == "--sec" {
|
|
||||||
if i + 1 < args.len() {
|
|
||||||
let nsec = &args[i + 1];
|
|
||||||
return SecretKey::parse(nsec)
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
|
|
||||||
} else {
|
|
||||||
return Err("--sec requires a value (nsec1...)".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quit(_ev: &Quit, cx: &mut App) {
|
fn quit(_ev: &Quit, cx: &mut App) {
|
||||||
log::info!("Gracefully quitting the application . . .");
|
log::info!("Gracefully quitting the application . . .");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
|
|||||||
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 651 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 696 KiB |
|
After Width: | Height: | Size: 506 KiB |
@@ -55,8 +55,8 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
|
|||||||
|
|
||||||
Ensure you have:
|
Ensure you have:
|
||||||
- [ ] Committed all changes
|
- [ ] Committed all changes
|
||||||
- [ ] Tagged the release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
- [ ] Tagged the release: `git tag -a v1.0.0-beta2 -m "Release v1.0.0-beta2"`
|
||||||
- [ ] Pushed the tag: `git push origin v1.0.0`
|
- [ ] Pushed the tag: `git push origin v1.0.0-beta2`
|
||||||
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
|
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
|
||||||
|
|
||||||
### 2. Fork and Submit
|
### 2. Fork and Submit
|
||||||
@@ -101,8 +101,8 @@ git push origin su.reya.coop
|
|||||||
To release a new version:
|
To release a new version:
|
||||||
|
|
||||||
1. Update version in workspace `Cargo.toml`
|
1. Update version in workspace `Cargo.toml`
|
||||||
2. Tag the new release: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
2. Tag the new release: `git tag -a v1.0.0-beta3 -m "Release v1.0.0-beta3"`
|
||||||
3. Push the tag: `git push origin v1.0.0`
|
3. Push the tag: `git push origin v1.0.0-beta3`
|
||||||
4. Run `./script/prepare-flathub.sh` to regenerate
|
4. Run `./script/prepare-flathub.sh` to regenerate
|
||||||
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
|
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
|
||||||
6. Update the manifest with new commit/tag and hashes
|
6. Update the manifest with new commit/tag and hashes
|
||||||
|
|||||||
@@ -35,7 +35,3 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
|
|||||||
snapcraft --destructive-mode --output "$SNAP_NAME"
|
snapcraft --destructive-mode --output "$SNAP_NAME"
|
||||||
|
|
||||||
echo "Created snap package: $SNAP_NAME"
|
echo "Created snap package: $SNAP_NAME"
|
||||||
echo ""
|
|
||||||
echo "This snap is unsigned (built without Snap Store credentials)."
|
|
||||||
echo "To install it locally, use:"
|
|
||||||
echo " sudo snap install --dangerous ./$SNAP_NAME"
|
|
||||||
|
|||||||
@@ -29,29 +29,23 @@ fi
|
|||||||
# Function to update version in a Cargo.toml file
|
# Function to update version in a Cargo.toml file
|
||||||
update_version() {
|
update_version() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
local tmp="${file}.tmp"
|
local backup="${file}.bak"
|
||||||
|
|
||||||
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
|
# Backup the original file
|
||||||
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
|
cp "$file" "$backup"
|
||||||
# suffix instead of the extended-regex flag, leaving a stray
|
|
||||||
# `Cargo.toml-E` behind and never updating the file.
|
# More flexible regex that handles various version formats and whitespace
|
||||||
# Writing to a temp file and moving it over works on both implementations.
|
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
|
||||||
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
|
|
||||||
&& mv "$tmp" "$file"; then
|
|
||||||
echo "✓ Updated version to $NEW_VERSION in $file"
|
echo "✓ Updated version to $NEW_VERSION in $file"
|
||||||
else
|
else
|
||||||
echo "Error: Failed to update version in $file"
|
echo "Error: Failed to update version in $file"
|
||||||
# Remove any partial temp file; the original file is untouched
|
# Restore original backup
|
||||||
rm -f "$tmp"
|
mv "$backup" "$file"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The substitution can silently match nothing (e.g. a `version.workspace` key),
|
# Remove the backup file
|
||||||
# so verify the new version actually landed before moving on.
|
rm -f "$backup"
|
||||||
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
|
|
||||||
echo "Error: Version line not found/updated in $file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update both Cargo.toml files
|
# Update both Cargo.toml files
|
||||||
|
|||||||
@@ -15,26 +15,11 @@ if [ "$#" -ne 1 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get system architecture (same mapping as script/bundle-snap)
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) ARCH_SUFFIX="x86_64" ;;
|
|
||||||
aarch64) ARCH_SUFFIX="aarch64" ;;
|
|
||||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
|
|
||||||
if [ ! -f "$snap_file" ]; then
|
|
||||||
echo "Snap file not found: $snap_file"
|
|
||||||
echo "Build it first with: script/bundle-snap $1"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rerun as root
|
# Rerun as root
|
||||||
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
|
||||||
|
|
||||||
snap remove coop || true
|
snap remove coop || true
|
||||||
mkdir -p snap
|
mkdir -p snap
|
||||||
rm -rf snap/unpacked
|
rm -rf snap/unpacked
|
||||||
unsquashfs -dest snap/unpacked "$snap_file"
|
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
|
||||||
snap try --classic snap/unpacked
|
snap try --classic snap/unpacked
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ pub fn run() -> Result<(), JsValue> {
|
|||||||
settings::init(window, cx);
|
settings::init(window, cx);
|
||||||
|
|
||||||
// Initialize the nostr client
|
// Initialize the nostr client
|
||||||
state::init(window, cx, None);
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(window, cx);
|
person::init(window, cx);
|
||||||
|
|||||||