Skip to content

Instantly share code, notes, and snippets.

@arturaz
Created April 19, 2025 08:37
Show Gist options
  • Save arturaz/413269f4f90fa4dc97c94fc120500d3a to your computer and use it in GitHub Desktop.
Save arturaz/413269f4f90fa4dc97c94fc120500d3a to your computer and use it in GitHub Desktop.
package app.utils
import app.data.{AppPushNotificationsState, PushNotificationData}
import cats.Applicative
import com.raquo.airstream.state.StrictSignal
import framework.utils.JSLogger
import retry.syntax.*
import retry.{HandlerDecision, RetryPolicies}
import typings.tauriAppsApi as tauri
import typings.tauriAppsPluginProcess.tauriAppsPluginProcessRequire
import scala.concurrent.Future
import tauri.windowMod.UserAttentionType
trait Tauri {
def osType: Tauri.OsType
def requestUserAttention(): Future[Unit]
def loseUserAttention(): Future[Unit]
def setBadge(number: Option[Int]): Future[Unit]
/** Checks if notifications are allowed.
*
* @note
* only used for debugging, you should use [[pushNotifications]] instead.
*/
def areNotificationsAllowed(): Future[Boolean]
/** Requests the permission to show notifications.
*
* @note
* only used for debugging, you should use [[pushNotifications]] instead.
*/
def requestNotificationsPermission()
: Future[AppPushNotificationsState.Granted.type | AppPushNotificationsState.Denied.type]
/** Shows a notification. */
def showNotification(data: PushNotificationData): Future[Unit]
/** Current push notifications state, [[None]] while it is being determined. */
val pushNotifications: StrictSignal[Option[AppPushNotificationsState]]
/** Becomes [[Some]] when an update is available. */
val appUpdates: Signal[Option[Tauri.AppUpdate]]
/** Returns the current Tauri app version. */
val appVersion: Signal[Option[String]]
/** Returns the current Tauri version. */
val tauriVersion: Signal[Option[String]]
}
object Tauri {
enum OsType derives CanEqual {
case Windows, Mac, Linux, Other
}
object OsType {
import typings.tauriAppsPluginOs.mod.OsType as TauriOsType
def fromTauri(tauri: TauriOsType): OsType = {
given CanEqual[TauriOsType, TauriOsType] = CanEqual.derived
if (tauri == TauriOsType.windows) OsType.Windows
else if (tauri == TauriOsType.macos) OsType.Mac
else if (tauri == TauriOsType.linux) OsType.Linux
else OsType.Other
}
}
def create(): Option[Tauri] = {
val log = framework.prelude.log.scoped("Tauri")
if (tauri.coreMod.isTauri()) {
log.debug("Tauri detected.")
Some(Impl(log))
} else {
log.debug("Tauri not detected.")
None
}
}
private class Impl(log: JSLogger) extends Tauri {
import typings.tauriAppsPluginNotification.{mod => notifications}
lazy val osType: OsType =
OsType.fromTauri(typings.tauriAppsPluginOs.mod.`type`())
def whenWindows[F[_]: Applicative](run: => F[Unit]): F[Unit] =
if (osType == OsType.Windows) run else Applicative[F].unit
def requestUserAttention() = {
log.debug("Requesting user attention...")
val tauriWindow = tauri.windowMod.getCurrentWindow()
for {
_ <- tauriWindow.requestUserAttention(UserAttentionType.Critical).toFuture
} yield ()
}
def loseUserAttention() = {
log.debug("Losing user attention...")
val tauriWindow = tauri.windowMod.getCurrentWindow()
for {
_ <- tauriWindow.requestUserAttention(null).toFuture
} yield ()
}
def setBadge(number: Option[Int]): Future[Unit] = {
val icon = number.map {
case i if i <= 0 => "./icons/overlay/icon_alert.png"
case i if i >= 10 => "./icons/overlay/icon_9plus.png"
case i => show"./icons/overlay/icon_$i.png"
}
log.debug(s"Setting badge to ${number} (icon=$icon)...")
val tauriWindow = tauri.windowMod.getCurrentWindow()
whenWindows(for {
resolvedPath <- icon.map(tauri.pathMod.resolveResource(_).toFuture).sequence
_ <- Future(log.debug(s"Setting badge to ${number} (icon=$icon, resolvedPath=$resolvedPath)..."))
_ <-
resolvedPath.fold2(
tauriWindow.setOverlayIcon().toFuture,
path => tauriWindow.setOverlayIcon(path).toFuture,
)
} yield ())
}
val pushNotifications: StrictSignal[Option[AppPushNotificationsState]] =
log.scoped("PushNotifications").pipe { log =>
val rx = Var(Option.empty[AppPushNotificationsState])
val sink = rx.someWriter
areNotificationsAllowed().onComplete {
case util.Success(true) =>
sink.onNext(AppPushNotificationsState.Granted)
case util.Success(false) =>
def storageKey = "Tauri.pushNotificationsEnabled"
def storedNotificationsEnabled() = {
val value = window.localStorage.getItem(storageKey)
log.debug(s"Local storage: '$storageKey' = '$value'")
value != null
}
def storeNotificationsEnabled() = window.localStorage.setItem(storageKey, "true")
def defaultBehaviour() = {
sink.onNext(
AppPushNotificationsState.Askable(ask =
() =>
requestNotificationsPermission()
.map {
case AppPushNotificationsState.Granted => Right("Granted")
case AppPushNotificationsState.Denied => Left("Denied")
}
.tap(_.foreach {
case Left(_) => sink.onNext(AppPushNotificationsState.Denied)
case Right(_) =>
storeNotificationsEnabled()
sink.onNext(AppPushNotificationsState.Granted)
})
)
)
}
if (storedNotificationsEnabled()) {
log.debug("Notifications were allowed previously, trying to grant again...")
requestNotificationsPermission().foreach {
case AppPushNotificationsState.Granted =>
log.debug("Granted notifications again.")
sink.onNext(AppPushNotificationsState.Granted)
case AppPushNotificationsState.Denied =>
log.debug("Notifications were denied.")
defaultBehaviour()
}
} else {
defaultBehaviour()
}
case util.Failure(err) =>
log.error(s"Failed to check notifications permission: $err")
sink.onNext(AppPushNotificationsState.Denied)
}
rx.signal
}
def areNotificationsAllowed(): Future[Boolean] =
notifications.isPermissionGranted().toFuture
def requestNotificationsPermission()
: Future[AppPushNotificationsState.Granted.type | AppPushNotificationsState.Denied.type] = {
notifications.requestPermission().toFuture.map { state =>
import typings.std.NotificationPermission
given CanEqual1[NotificationPermission] = CanEqual.derived
if (state == NotificationPermission.granted) AppPushNotificationsState.Granted
else AppPushNotificationsState.Denied
}
}
def showNotification(data: PushNotificationData): Future[Unit] = {
for {
granted <- areNotificationsAllowed()
} yield {
if (granted) {
notifications.sendNotification(
notifications
.Options(title = data.title)
.pipe(opts => data.body.fold2(opts, opts.setBody))
)
} else {
log.error(show"Cannot send push notification, permission is not granted: ${data.pprintWithoutColors}")
}
}
}
val appUpdates = log.scoped("AppUpdates").pipe { log =>
/** How often to check for updates. */
val checkEvery = 5.minutes
import typings.{tauriAppsPluginUpdater => updater}
import updater.mod
import updater.{tauriAppsPluginUpdaterStrings as strings}
val io = (
IO(log.debug("Checking for updates...")) *> IO
.fromFuture(IO(mod.check().toFuture.map(Option(_))))
.flatMap {
case None => IO(log.debug("No update available.")).as(None)
case Some(update) =>
IO.fromFuture(IO {
log.debug(
show"Update available (current version: ${update.currentVersion}, update version: ${update.version})" +
show", downloading"
)
update
.download(
_.matchDynamic(_.event)
.on(strings.Started)((v: updater.anon.Data) =>
log.debug(s"Update download started, bytes to download: ${v.data.contentLength}")
)
.on(strings.Progress)((v: updater.anon.Event) =>
log.debug(s"Update download progress: ${v.data.chunkLength} bytes received")
)
.on(strings.Finished)((v: updater.anon.`0`) => log.debug("Update download finished."))
.perform
)
.toFuture
}).as(
Some(new AppUpdate {
def currentVersion: String = update.currentVersion
def version: String = update.version
def install(): Future[Unit] = {
for {
_ <- Future(log.info("Installing update..."))
_ <- update.install().toFuture
_ <- Future(log.info("App update installed, restarting..."))
_ <- typings.tauriAppsPluginProcess.mod.relaunch().toFuture
} yield ()
}
})
)
}
).retryingOnErrors(
RetryPolicies.constantDelay[IO](1.second),
(err, details) =>
IO(log.info(s"Update check failed ${details.asString}, retrying: $err")).as(HandlerDecision.Continue),
)
/** Repeats until it finds an update. */
lazy val repeatingIO: IO[AppUpdate] = io.flatMap {
case Some(value) => value.pure
case None => IO.sleep(checkEvery) *> repeatingIO
}
Signal.fromFuture(repeatingIO.unsafeToFuture())
}
val appVersion = Signal.fromFuture(tauri.appMod.getVersion().toFuture)
val tauriVersion = Signal.fromFuture(tauri.appMod.getTauriVersion().toFuture)
}
trait AppUpdate {
/** The current version of the app. */
def currentVersion: String
/** The update version. */
def version: String
/** Installs the update and restarts the app. */
def install(): Future[Unit]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment