Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Set app badge to notification count periodically on macOS
Archived rudolfs opened 1 year ago
11 files changed +218 -60 ea43ffd4 a720e8a9
modified Cargo.lock
@@ -1925,6 +1925,16 @@ dependencies = [
]

[[package]]
+
name = "gethostname"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
+
dependencies = [
+
 "rustix",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3457,6 +3467,17 @@ dependencies = [
]

[[package]]
+
name = "os_info"
+
version = "3.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6e6520c8cc998c5741ee68ec1dc369fc47e5f0ea5320018ecf2a1ccd6328f48b"
+
dependencies = [
+
 "log",
+
 "serde",
+
 "windows-sys 0.52.0",
+
]
+

+
[[package]]
name = "os_pipe"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4112,6 +4133,8 @@ dependencies = [
 "anyhow",
 "base64 0.22.1",
 "log",
+
 "objc2-app-kit",
+
 "objc2-foundation",
 "radicle",
 "radicle-surf",
 "radicle-types",
@@ -4122,6 +4145,7 @@ dependencies = [
 "tauri-plugin-clipboard-manager",
 "tauri-plugin-dialog",
 "tauri-plugin-log",
+
 "tauri-plugin-os",
 "tauri-plugin-shell",
 "tauri-plugin-window-state",
 "thiserror 1.0.69",
@@ -5288,6 +5312,15 @@ dependencies = [
]

[[package]]
+
name = "sys-locale"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+
dependencies = [
+
 "libc",
+
]
+

+
[[package]]
name = "system-deps"
version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5584,6 +5617,24 @@ dependencies = [
]

[[package]]
+
name = "tauri-plugin-os"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738"
+
dependencies = [
+
 "gethostname 0.5.0",
+
 "log",
+
 "os_info",
+
 "serde",
+
 "serde_json",
+
 "serialize-to-javascript",
+
 "sys-locale",
+
 "tauri",
+
 "tauri-plugin",
+
 "thiserror 2.0.7",
+
]
+

+
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7288,7 +7339,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
-
 "gethostname",
+
 "gethostname 0.4.3",
 "rustix",
 "x11rb-protocol",
]
modified crates/radicle-tauri/Cargo.toml
@@ -32,6 +32,16 @@ thiserror = { version = "1.0.64" }
ts-rs = { version = "10.0.0", features = ["serde-json-impl", "no-serde-warnings"] }
tokio = { version = "1.40.0", features = ["time"] }
tauri-plugin-dialog = { version = "2.2.0" }
+
tauri-plugin-os = "2"
+

+
[target.'cfg(target_os = "macos")'.dependencies]
+
objc2-foundation = { version = "0.2.2", features = [
+
  "NSThread",
+
] }
+
objc2-app-kit = { version = "0.2.2", features = [
+
  "NSApplication",
+
  "NSDockTile",
+
] }

[features]
# by default Tauri runs in production mode
modified crates/radicle-tauri/capabilities/default.json
@@ -2,7 +2,9 @@
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
-
  "windows": ["main"],
+
  "windows": [
+
    "main"
+
  ],
  "permissions": [
    "core:path:default",
    "core:event:default",
@@ -17,6 +19,7 @@
    "clipboard-manager:default",
    "clipboard-manager:allow-write-text",
    "log:default",
-
    "dialog:default"
+
    "dialog:default",
+
    "os:default"
  ]
-
}
+
}

\ No newline at end of file
modified crates/radicle-tauri/src/commands/inbox.rs
@@ -179,3 +179,28 @@ pub fn clear_notifications(

    Ok(())
}
+

+
#[tauri::command]
+
#[cfg(target_os = "macos")]
+
pub fn set_badge(count: i32) {
+
    use objc2_app_kit::NSApp;
+
    use objc2_foundation::{MainThreadMarker, NSString};
+

+
    let label = if count > 0 {
+
        Some(NSString::from_str(&format!("{}", count)))
+
    } else {
+
        None
+
    };
+

+
    if let Some(thread) = MainThreadMarker::new() {
+
        unsafe {
+
            let app = NSApp(thread);
+
            let dock_tile = app.dockTile();
+

+
            dock_tile.setBadgeLabel(label.as_deref());
+
            dock_tile.display();
+
        }
+
    } else {
+
        eprintln!("Failed to obtain MainThreadMarker.");
+
    }
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -15,6 +15,7 @@ use commands::{auth, cob, diff, inbox, profile, repo, thread};
pub fn run() {
    #[cfg(debug_assertions)]
    let builder = tauri::Builder::default()
+
        .plugin(tauri_plugin_os::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_log::Builder::new().build());
    #[cfg(not(debug_assertions))]
@@ -84,9 +85,11 @@ pub fn run() {
            repo::diff_stats,
            repo::list_commits,
            diff::get_diff,
-
            inbox::list_notifications,
-
            inbox::count_notifications_by_repo,
            inbox::clear_notifications,
+
            inbox::count_notifications_by_repo,
+
            inbox::list_notifications,
+
            #[cfg(target_os = "macos")]
+
            inbox::set_badge,
            cob::get_embed,
            cob::save_embed_to_disk,
            cob::save_embed_by_path,
modified package-lock.json
@@ -14,6 +14,7 @@
        "@tauri-apps/plugin-clipboard-manager": "^2.2.0",
        "@tauri-apps/plugin-dialog": "^2.2.0",
        "@tauri-apps/plugin-log": "^2.2.0",
+
        "@tauri-apps/plugin-os": "^2.2.0",
        "@tauri-apps/plugin-shell": "^2.2.0",
        "@tauri-apps/plugin-window-state": "^2.2.0"
      },
@@ -1312,6 +1313,15 @@
        "@tauri-apps/api": "^2.0.0"
      }
    },
+
    "node_modules/@tauri-apps/plugin-os": {
+
      "version": "2.2.0",
+
      "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.2.0.tgz",
+
      "integrity": "sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA==",
+
      "license": "MIT OR Apache-2.0",
+
      "dependencies": {
+
        "@tauri-apps/api": "^2.0.0"
+
      }
+
    },
    "node_modules/@tauri-apps/plugin-shell": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.0.tgz",
modified package.json
@@ -29,6 +29,7 @@
    "@tauri-apps/plugin-clipboard-manager": "^2.2.0",
    "@tauri-apps/plugin-dialog": "^2.2.0",
    "@tauri-apps/plugin-log": "^2.2.0",
+
    "@tauri-apps/plugin-os": "^2.2.0",
    "@tauri-apps/plugin-shell": "^2.2.0",
    "@tauri-apps/plugin-window-state": "^2.2.0"
  },
modified src/App.svelte
@@ -1,13 +1,7 @@
<script lang="ts">
-
  import type { UnlistenFn } from "@tauri-apps/api/event";
-

  import { onDestroy, onMount } from "svelte";

-
  import { invoke } from "@app/lib/invoke";
-
  import { listen } from "@tauri-apps/api/event";
-

  import * as router from "@app/lib/router";
-
  import { nodeRunning } from "@app/lib/events";
  import { theme } from "@app/components/ThemeSwitch.svelte";
  import { unreachable } from "@app/lib/utils";

@@ -19,51 +13,27 @@
  import Patch from "@app/views/repo/Patch.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
  import Repos from "./views/home/Repos.svelte";
-
  import { dynamicInterval, checkAuth } from "./lib/auth";
+
  import { checkAuthPeriodically } from "./lib/auth";
+
  import {
+
    registerNodeEventListeners,
+
    unregisterNodeEventListeners,
+
  } from "./lib/events";
+
  import {
+
    registerNotificationBadgePoll,
+
    unregisterNotificationBadgePoll,
+
  } from "./lib/notification/appBadge";

  const activeRouteStore = router.activeRouteStore;

-
  let unlistenEvents: UnlistenFn | undefined = undefined;
-
  let unlistenNodeEvents: UnlistenFn | undefined = undefined;
-

  onMount(async () => {
-
    if (window.__TAURI_INTERNALS__) {
-
      unlistenEvents = await listen("event", () => {
-
        // Add handler for incoming events
-
      });
-

-
      unlistenNodeEvents = await listen<boolean>("node_running", event => {
-
        nodeRunning.set(event.payload);
-
      });
-
    }
-

-
    try {
-
      await invoke("authenticate");
-
      void router.loadFromLocation();
-
      void dynamicInterval(
-
        checkAuth,
-
        import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
-
      );
-
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-
    } catch (e: any) {
-
      void router.push({
-
        resource: "authenticationError",
-
        params: {
-
          error: e.err,
-
          hint: e.hint,
-
        },
-
      });
-
      void dynamicInterval(checkAuth, 1000);
-
    }
+
    await registerNodeEventListeners();
+
    await checkAuthPeriodically(true);
+
    await registerNotificationBadgePoll();
  });

  onDestroy(() => {
-
    if (unlistenEvents) {
-
      unlistenEvents();
-
    }
-
    if (unlistenNodeEvents) {
-
      unlistenNodeEvents();
-
    }
+
    unregisterNodeEventListeners();
+
    unregisterNotificationBadgePoll();
  });

  $effect(() => document.documentElement.setAttribute("data-theme", $theme));
modified src/lib/auth.ts
@@ -1,11 +1,16 @@
-
import { invoke } from "@app/lib/invoke";
-
import { activeRouteStore } from "@app/lib/router";
import { get } from "svelte/store";
+
import { writable } from "svelte/store";
+

import * as router from "@app/lib/router";
+
import { activeRouteStore } from "@app/lib/router";
+
import { invoke } from "@app/lib/invoke";

+
export const authenticated = writable<boolean>(false);
+

+
let lock = false;
let intervalId: ReturnType<typeof setTimeout>;

-
export function dynamicInterval(callback: () => void, period: number) {
+
function dynamicInterval(callback: () => void, period: number) {
  clearTimeout(intervalId);

  intervalId = setTimeout(() => {
@@ -14,22 +19,27 @@ export function dynamicInterval(callback: () => void, period: number) {
  }, period);
}

-
let lock = false;
-

-
export async function checkAuth() {
+
export async function checkAuthPeriodically(appLaunch: boolean = false) {
  try {
    if (lock) {
      return;
    }
    lock = true;
    await invoke("authenticate");
-
    if (get(activeRouteStore).resource === "authenticationError") {
+
    authenticated.set(true);
+
    if (appLaunch) {
+
      await router.loadFromLocation();
+
    } else if (get(activeRouteStore).resource === "authenticationError") {
      window.history.back();
    }
-
    dynamicInterval(checkAuth, import.meta.env.VITE_AUTH_LONG_DELAY || 30_000);
+
    dynamicInterval(
+
      checkAuthPeriodically,
+
      import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
+
    );
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  } catch (e: any) {
-
    if (get(activeRouteStore).resource !== "authenticationError") {
+
    authenticated.set(false);
+
    if (appLaunch || get(activeRouteStore).resource !== "authenticationError") {
      await router.push({
        resource: "authenticationError",
        params: {
@@ -37,7 +47,7 @@ export async function checkAuth() {
          hint: e.hint,
        },
      });
-
      dynamicInterval(checkAuth, 1000);
+
      dynamicInterval(checkAuthPeriodically, 1000);
    }
  } finally {
    lock = false;
modified src/lib/events.ts
@@ -1,3 +1,30 @@
+
import type { UnlistenFn } from "@tauri-apps/api/event";
+

+
import { listen } from "@tauri-apps/api/event";
import { writable } from "svelte/store";

export const nodeRunning = writable<boolean>(false);
+

+
let unlistenEvents: UnlistenFn | undefined = undefined;
+
let unlistenNodeEvents: UnlistenFn | undefined = undefined;
+

+
export async function registerNodeEventListeners() {
+
  if (window.__TAURI_INTERNALS__) {
+
    unlistenEvents = await listen("event", () => {
+
      // Add handler for incoming events
+
    });
+

+
    unlistenNodeEvents = await listen<boolean>("node_running", event => {
+
      nodeRunning.set(event.payload);
+
    });
+
  }
+
}
+

+
export function unregisterNodeEventListeners() {
+
  if (unlistenEvents) {
+
    unlistenEvents();
+
  }
+
  if (unlistenNodeEvents) {
+
    unlistenNodeEvents();
+
  }
+
}
added src/lib/notification/appBadge.ts
@@ -0,0 +1,48 @@
+
import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
+

+
import sum from "lodash/sum";
+
import { SvelteMap } from "svelte/reactivity";
+
import { get } from "svelte/store";
+
import { platform } from "@tauri-apps/plugin-os";
+

+
import { authenticated } from "@app/lib/auth";
+
import { invoke } from "@app/lib/invoke";
+
import { nodeRunning } from "@app/lib/events";
+

+
let pollHandle: ReturnType<typeof setInterval> | undefined = undefined;
+
let pollingInProgress: boolean = false;
+

+
async function setNotificationBadge() {
+
  try {
+
    pollingInProgress = true;
+

+
    const count = await invoke<Record<string, NotificationCount>>(
+
      "count_notifications_by_repo",
+
    );
+
    const notificationCount = new SvelteMap(Object.entries(count));
+
    await invoke("set_badge", {
+
      count: sum(Array.from(notificationCount.values()).map(c => c.count)),
+
    });
+
  } finally {
+
    pollingInProgress = false;
+
  }
+
}
+

+
export async function registerNotificationBadgePoll() {
+
  if (window.__TAURI_OS_PLUGIN_INTERNALS__ && platform() === "macos") {
+
    await setNotificationBadge();
+
    pollHandle = setInterval(async () => {
+
      if (pollingInProgress || !get(nodeRunning) || !get(authenticated)) {
+
        return;
+
      }
+

+
      await setNotificationBadge();
+
    }, 5_000);
+
  }
+
}
+

+
export function unregisterNotificationBadgePoll() {
+
  if (pollHandle) {
+
    clearInterval(pollHandle);
+
  }
+
}