Created
April 19, 2025 08:37
-
-
Save arturaz/413269f4f90fa4dc97c94fc120500d3a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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