use std::path::PathBuf;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use maud::{Markup, html};
use serde::Deserialize;
use crate::config::Config;
use crate::session::SessionStore;
use crate::view::export::resolve_server_url;
use crate::view::guard::guard_view;
use crate::view::icons;
use crate::view::{is_htmx, main_with_content};
const PLUGIN_MANIFEST: &str = include_str!("../../plugin/notify.desktop/manifest.json");
const PLUGIN_SERVICE: &str = include_str!("../../plugin/notify.desktop/Service.qml");
const PLUGIN_PANEL: &str = include_str!("../../plugin/notify.desktop/Panel.qml");
const PLUGIN_MODEL: &str = include_str!("../../plugin/notify.desktop/Model.js");
const PLUGIN_BAR_WIDGET: &str = include_str!("../../plugin/notify.desktop/BarWidget.qml");
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DownloadQuery {
pub url: Option<String>,
}
pub fn get_dist_dir() -> PathBuf {
if let Ok(dir) = std::env::var("NOTIFY_DIST_DIR") {
let p = PathBuf::from(dir.trim());
if p.exists() {
return p;
}
}
for candidate in ["./dist", "/app/dist", "../dist"] {
let p = PathBuf::from(candidate);
if p.exists() {
return p;
}
}
PathBuf::from("./dist")
}
fn escape_bash(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
}
pub fn url_encode(input: &str) -> String {
let mut encoded = String::new();
for b in input.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(b as char);
}
_ => {
encoded.push_str(&format!("%{:02X}", b));
}
}
}
encoded
}
pub fn build_mac_install_script(server_url: &str, pin: &str) -> String {
let escaped_url = escape_bash(server_url);
let escaped_pin = escape_bash(pin);
format!(
r#"#!/usr/bin/env bash
# Notify macOS App Installer
set -euo pipefail
if ! command -v curl >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
echo "Error: curl and unzip are required to install Notify for macOS." >&2
exit 1
fi
SERVER_URL="${{NOTIFY_SERVER_URL:-"{escaped_url}"}}"
PIN="${{NOTIFY_PIN:-"{escaped_pin}"}}"
echo "==> Installing Notify for macOS..."
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${{TMP_DIR}}"' EXIT
echo "==> Downloading Notify for macOS from ${{SERVER_URL}}..."
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/download/Notify-mac.zip?pin=${{PIN}}" -o "${{TMP_DIR}}/Notify-mac.zip"
echo "==> Extracting Notify.app..."
unzip -q -o "${{TMP_DIR}}/Notify-mac.zip" -d "${{TMP_DIR}}"
APP_DEST="/Applications/Notify.app"
if [ ! -w "/Applications" ]; then
APP_DEST="${{HOME}}/Applications/Notify.app"
mkdir -p "${{HOME}}/Applications"
fi
rm -rf "${{APP_DEST}}"
cp -R "${{TMP_DIR}}/Notify.app" "${{APP_DEST}}"
# Configure ~/.config/notify/notify.json
NOTIFY_CONFIG_DIR="${{HOME}}/.config/notify"
mkdir -p "${{NOTIFY_CONFIG_DIR}}"
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/install/omarchy/config?pin=${{PIN}}" -o "${{NOTIFY_CONFIG_DIR}}/notify.json"
echo "โ Installed Notify.app to ${{APP_DEST}}"
echo "โ Configured ${{NOTIFY_CONFIG_DIR}}/notify.json"
echo "๐ Launching Notify..."
open "${{APP_DEST}}" || true
"#
)
}
pub fn build_linux_install_script(server_url: &str, pin: &str) -> String {
let escaped_url = escape_bash(server_url);
let escaped_pin = escape_bash(pin);
format!(
r#"#!/usr/bin/env bash
# Notify Linux desktop app installer
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required to install Notify for Linux." >&2
exit 1
fi
SERVER_URL="${{NOTIFY_SERVER_URL:-"{escaped_url}"}}"
PIN="${{NOTIFY_PIN:-"{escaped_pin}"}}"
APP_DIR="${{XDG_DATA_HOME:-"${{HOME}}/.local/share"}}/notify"
BIN_DIR="${{HOME}}/.local/bin"
APPLICATIONS_DIR="${{XDG_DATA_HOME:-"${{HOME}}/.local/share"}}/applications"
TMP_FILE="$(mktemp)"
trap 'rm -f "${{TMP_FILE}}"' EXIT
mkdir -p "${{APP_DIR}}" "${{BIN_DIR}}" "${{APPLICATIONS_DIR}}"
echo "==> Downloading Notify for Linux from ${{SERVER_URL}}..."
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/download/Notify-linux-x86_64.AppImage" -o "${{TMP_FILE}}"
install -m 755 "${{TMP_FILE}}" "${{APP_DIR}}/Notify.AppImage"
ln -sfn "${{APP_DIR}}/Notify.AppImage" "${{BIN_DIR}}/notify"
cat > "${{APPLICATIONS_DIR}}/notify.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Notify
Comment=Send quick notifications
Exec="${{APP_DIR}}/Notify.AppImage"
Terminal=false
Categories=Network;Utility;
EOF
NOTIFY_CONFIG_DIR="${{HOME}}/.config/notify"
mkdir -p "${{NOTIFY_CONFIG_DIR}}"
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/install/omarchy/config" -o "${{NOTIFY_CONFIG_DIR}}/notify.json"
echo "โ Installed Notify to ${{APP_DIR}}/Notify.AppImage"
echo "โ Added desktop entry to ${{APPLICATIONS_DIR}}/notify.desktop"
echo "โ Configured ${{NOTIFY_CONFIG_DIR}}/notify.json"
echo "You can now launch Notify from your application menu or with: notify"
"#
)
}
pub fn build_omarchy_install_script(server_url: &str, pin: &str) -> String {
let escaped_url = escape_bash(server_url);
let escaped_pin = escape_bash(pin);
format!(
r#"#!/usr/bin/env bash
# Notify plugin installer for Omarchy
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required to install the Notify plugin." >&2
exit 1
fi
SERVER_URL="${{NOTIFY_SERVER_URL:-"{escaped_url}"}}"
PIN="${{NOTIFY_PIN:-"{escaped_pin}"}}"
echo "==> Installing Notify plugin for Omarchy..."
omarchy_plugins_dir="${{HOME}}/.config/omarchy/plugins"
plugin_target="${{omarchy_plugins_dir}}/notify.desktop"
notify_config_dir="${{HOME}}/.config/notify"
mkdir -p "${{plugin_target}}"
mkdir -p "${{notify_config_dir}}"
echo "==> Downloading plugin files from ${{SERVER_URL}}..."
FILES=(
"manifest.json"
"Service.qml"
"Panel.qml"
"Model.js"
"BarWidget.qml"
)
for file in "${{FILES[@]}}"; do
echo " -> ${{file}}"
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/install/omarchy/plugin/${{file}}?pin=${{PIN}}" -o "${{plugin_target}}/${{file}}"
done
echo "==> Setting up configuration..."
if [ -f "${{notify_config_dir}}/notify.json" ]; then
bak="${{notify_config_dir}}/notify.json.bak.$(date +%s)"
cp "${{notify_config_dir}}/notify.json" "${{bak}}"
echo " (Backed up existing config to ${{bak}})"
fi
curl -fsSL -H "X-PIN: ${{PIN}}" "${{SERVER_URL}}/install/omarchy/config?pin=${{PIN}}" -o "${{notify_config_dir}}/notify.json"
echo "โ Installed configuration to ${{notify_config_dir}}/notify.json"
# Reload and enable plugin in Omarchy
if command -v omarchy-restart-shell >/dev/null 2>&1; then
echo "==> Restarting Omarchy shell..."
omarchy-restart-shell >/dev/null 2>&1 || true
elif command -v omarchy-shell >/dev/null 2>&1; then
echo "==> Rescanning Omarchy shell plugins..."
omarchy-shell shell rescanPlugins 2>/dev/null || true
fi
if command -v omarchy >/dev/null 2>&1; then
echo "==> Enabling plugin in Omarchy..."
omarchy plugin enable notify.desktop 2>/dev/null || true
fi
echo "๐ Notify plugin installed and configured successfully!"
"#
)
}
pub fn install_content(server_url: &str, pin: &str) -> Markup {
let pin_encoded = url_encode(pin);
let omarchy_install_url = format!("{}/install/omarchy.sh?pin={}", server_url, pin_encoded);
let omarchy_install_cmd = format!("curl -fsSL \"{}\" | bash", omarchy_install_url);
let mac_install_url = format!("{}/install/mac.sh?pin={}", server_url, pin_encoded);
let mac_install_cmd = format!("curl -fsSL \"{}\" | bash", mac_install_url);
let mac_download_url = format!("{}/download/Notify-mac.zip?pin={}", server_url, pin_encoded);
let linux_install_url = format!("{}/install/linux.sh?pin={}", server_url, pin_encoded);
let linux_install_cmd = format!("curl -fsSL \"{}\" | bash", linux_install_url);
let linux_download_url = format!(
"{}/download/Notify-linux-x86_64.AppImage?pin={}",
server_url, pin_encoded
);
let linux_deb_download_url = format!(
"{}/download/Notify-linux-x86_64.deb?pin={}",
server_url, pin_encoded
);
html! {
div class="flex min-h-screen w-full flex-col" {
nav class="navbar bg-base-100 shadow-sm border-b border-base-200 px-4" {
div class="navbar-start" {
a
href="/"
class="btn btn-ghost text-xl"
hx-get="/notify"
hx-target="#main"
hx-swap="innerHTML"
hx-push-url="/" {
"Notify"
}
}
div class="navbar-end gap-2" {
a
href="/debug"
class="btn btn-ghost btn-sm"
hx-get="/debug"
hx-target="#main"
hx-swap="innerHTML"
hx-push-url="true" {
"Debug"
}
a
href="/"
class="btn btn-ghost btn-sm"
hx-get="/notify"
hx-target="#main"
hx-swap="innerHTML"
hx-push-url="/" {
"Back to notifications"
}
}
}
div class="w-full flex-1 p-4 sm:p-8" {
div class="mx-auto w-full max-w-5xl" {
div class="mb-8" {
p class="text-sm font-semibold uppercase tracking-widest text-base-content/60" {
"Integrations & Clients"
}
h1 class="text-3xl font-bold" { "Install Plugins & Apps" }
p class="mt-1 text-base-content/70" {
"Download and install pre-configured apps for Linux and macOS."
}
}
div class="flex flex-col gap-8" {
// Section 1: macOS desktop app
div class="card card-border bg-base-200 border-base-300 shadow-sm" {
div class="card-body p-6 sm:p-8" {
div class="flex flex-wrap items-start justify-between gap-4" {
div class="flex items-center gap-3" {
div class="flex size-12 items-center justify-center rounded-box bg-primary/10 text-primary" {
(icons::apple())
}
div {
h2 class="card-title text-2xl font-bold" { "macOS App" }
p class="text-sm text-base-content/70" {
"Native desktop app built with Tauri. Pre-configured for this server."
}
}
}
div {
span class="badge badge-success badge-outline" { "Ready to install" }
}
}
div class="divider my-2" {}
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="flex flex-col gap-4" {
h3 class="font-semibold text-base" { "What's included" }
ul class="space-y-2 text-sm text-base-content/80" {
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Native macOS application (universal Apple Silicon & Intel binary)" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Trigger quick actions (heart, cat, emergency) and custom messages" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Compatible with Omarchy profiles in ~/.config/notify" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span {
strong { "Pre-configured: " }
"Connects directly to "
code class="font-mono text-xs bg-base-300 px-1.5 py-0.5 rounded" { (server_url) }
}
}
}
div class="mt-2 flex flex-wrap gap-3" {
a
href=(mac_download_url)
class="btn btn-primary btn-sm gap-2" {
(icons::apple())
"Download Notify.app (.zip)"
}
a
href=(mac_install_url)
target="_blank"
class="btn btn-outline btn-sm gap-2" {
(icons::terminal())
"View macOS Installer Script"
}
}
}
div class="flex flex-col gap-3" {
h3 class="font-semibold text-base" { "Quick Terminal Install" }
p class="text-sm text-base-content/80" {
"Run this one-line command to download, configure, and install into /Applications:"
}
div class="rounded-box bg-base-300 p-3 font-mono text-xs overflow-x-auto select-all" {
(mac_install_cmd)
}
p class="text-xs text-base-content/60" {
"Downloads the app, sets up "
code class="font-mono text-xs bg-base-300 px-1 py-0.5 rounded" { "~/.config/notify/notify.json" }
", and launches Notify.app."
}
}
}
}
}
// Section 2: Linux desktop app
div class="card card-border bg-base-200 border-base-300 shadow-sm" {
div class="card-body p-6 sm:p-8" {
div class="flex flex-wrap items-start justify-between gap-4" {
div class="flex items-center gap-3" {
div class="flex size-12 items-center justify-center rounded-box bg-primary/10 text-primary" {
(icons::terminal())
}
div {
h2 class="card-title text-2xl font-bold" { "Linux App" }
p class="text-sm text-base-content/70" {
"Native Linux desktop app for x86_64, packaged as an AppImage."
}
}
}
span class="badge badge-success badge-outline" { "Ready to install" }
}
div class="divider my-2" {}
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="flex flex-col gap-4" {
h3 class="font-semibold text-base" { "What's included" }
ul class="space-y-2 text-sm text-base-content/80" {
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Linux AppImage and Debian package (DEB)" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Quick actions, custom messages, and server profiles" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "AppImage requires WebKitGTK 4.1 and FUSE 2 at runtime" }
}
}
div class="mt-2 flex flex-wrap gap-3" {
a href=(linux_download_url) class="btn btn-primary btn-sm gap-2" {
(icons::terminal())
"Download Linux AppImage"
}
a href=(linux_deb_download_url) class="btn btn-outline btn-sm gap-2" {
"Download Debian package"
}
}
}
div class="flex flex-col gap-3" {
h3 class="font-semibold text-base" { "Quick Terminal Install" }
p class="text-sm text-base-content/80" {
"Install the app, add it to your application menu, and configure this server:"
}
div class="rounded-box bg-base-300 p-3 font-mono text-xs overflow-x-auto select-all" {
(linux_install_cmd)
}
p class="text-xs text-base-content/60" {
"The installer places the app in ~/.local/share/notify and creates a ~/.local/bin/notify command."
}
}
}
}
}
// Section 3: Omarchy Linux Plugin
div class="card card-border bg-base-200 border-base-300 shadow-sm" {
div class="card-body p-6 sm:p-8" {
div class="flex flex-wrap items-start justify-between gap-4" {
div class="flex items-center gap-3" {
div class="flex size-12 items-center justify-center rounded-box bg-primary/10 text-primary" {
(icons::terminal())
}
div {
h2 class="card-title text-2xl font-bold" { "Omarchy Plugin" }
p class="text-sm text-base-content/70" {
"Quickshell status bar widget and background notification daemon for Omarchy Linux."
}
}
}
div {
span class="badge badge-success badge-outline" { "Ready to install" }
}
}
div class="divider my-2" {}
div class="grid grid-cols-1 md:grid-cols-2 gap-6" {
div class="flex flex-col gap-4" {
h3 class="font-semibold text-base" { "What's included" }
ul class="space-y-2 text-sm text-base-content/80" {
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Real-time background polling for incoming notifications" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Status bar widget in the Omarchy top bar" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span { "Profile manager popup panel for multi-server setups" }
}
li class="flex items-start gap-2" {
span class="text-success font-bold" { "โ" }
span {
strong { "Direct configuration: " }
"Pre-configured to connect directly to "
code class="font-mono text-xs bg-base-300 px-1.5 py-0.5 rounded" { (server_url) }
" with your current PIN."
}
}
}
div class="mt-2 flex flex-wrap gap-3" {
a
href=(omarchy_install_url)
target="_blank"
class="btn btn-outline btn-sm gap-2" {
(icons::terminal())
"View Installer Script"
}
}
}
div class="flex flex-col gap-3" {
h3 class="font-semibold text-base" { "Quick Install" }
p class="text-sm text-base-content/80" {
"Run this one-line command in your terminal to download and set up the plugin:"
}
div class="rounded-box bg-base-300 p-3 font-mono text-xs overflow-x-auto select-all" {
(omarchy_install_cmd)
}
p class="text-xs text-base-content/60" {
"The script automatically downloads all plugin files, writes "
code class="font-mono text-xs bg-base-300 px-1 py-0.5 rounded" { "~/.config/notify/notify.json" }
", and enables the plugin in Omarchy."
}
}
}
}
}
}
}
}
}
}
}
#[get("/install")]
pub async fn install_page(
req: HttpRequest,
session_store: web::Data<SessionStore>,
config: web::Data<Config>,
) -> impl Responder {
if !session_store.is_authenticated(&req) {
let guard = guard_view(None);
return if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(guard.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(main_with_content(guard).into_string())
};
}
let server_url = resolve_server_url(&req, None);
let content = install_content(&server_url, &config.pin);
if is_htmx(&req) {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into_string())
} else {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(main_with_content(content).into_string())
}
}
pub async fn omarchy_installer_script(
req: HttpRequest,
session_store: web::Data<SessionStore>,
query: web::Query<DownloadQuery>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized()
.content_type("text/plain; charset=utf-8")
.body("Unauthorized: Invalid or missing PIN\n");
}
let server_url = resolve_server_url(&req, query.url.as_deref());
let script = build_omarchy_install_script(&server_url, &config.pin);
HttpResponse::Ok()
.content_type("text/x-shellscript; charset=utf-8")
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(script)
}
pub async fn mac_installer_script(
req: HttpRequest,
session_store: web::Data<SessionStore>,
query: web::Query<DownloadQuery>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized()
.content_type("text/plain; charset=utf-8")
.body("Unauthorized: Invalid or missing PIN\n");
}
let server_url = resolve_server_url(&req, query.url.as_deref());
let script = build_mac_install_script(&server_url, &config.pin);
HttpResponse::Ok()
.content_type("text/x-shellscript; charset=utf-8")
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(script)
}
pub async fn linux_installer_script(
req: HttpRequest,
session_store: web::Data<SessionStore>,
query: web::Query<DownloadQuery>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized()
.content_type("text/plain; charset=utf-8")
.body("Unauthorized: Invalid or missing PIN\n");
}
let server_url = resolve_server_url(&req, query.url.as_deref());
let script = build_linux_install_script(&server_url, &config.pin);
HttpResponse::Ok()
.content_type("text/x-shellscript; charset=utf-8")
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(script)
}
pub async fn download_file(
req: HttpRequest,
session_store: web::Data<SessionStore>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized().finish();
}
let filename = req.match_info().query("filename");
if filename.is_empty() || filename.contains("..") || filename.starts_with('/') {
return HttpResponse::BadRequest().body("Invalid filename");
}
let dist_dir = get_dist_dir();
let file_path = dist_dir.join(filename);
if !file_path.exists() || !file_path.is_file() {
return HttpResponse::NotFound()
.body(format!("File '{filename}' not found in distribution"));
}
let bytes = match std::fs::read(&file_path) {
Ok(b) => b,
Err(e) => {
log::error!("Failed to read distribution file {file_path:?}: {e}");
return HttpResponse::InternalServerError().finish();
}
};
let content_type = if filename.ends_with(".zip") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
"application/gzip"
} else if filename.ends_with(".json") {
"application/json"
} else if filename.ends_with(".sh") || filename.ends_with(".txt") || filename == "SHA256SUMS" {
"text/plain; charset=utf-8"
} else {
"application/octet-stream"
};
HttpResponse::Ok()
.content_type(content_type)
.insert_header((
actix_web::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
))
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(bytes)
}
pub async fn omarchy_plugin_file(
req: HttpRequest,
session_store: web::Data<SessionStore>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized().finish();
}
let filename = req.match_info().query("filename");
match filename {
"manifest.json" => HttpResponse::Ok()
.content_type("application/json; charset=utf-8")
.body(PLUGIN_MANIFEST),
"Service.qml" => HttpResponse::Ok()
.content_type("text/plain; charset=utf-8")
.body(PLUGIN_SERVICE),
"Panel.qml" => HttpResponse::Ok()
.content_type("text/plain; charset=utf-8")
.body(PLUGIN_PANEL),
"Model.js" => HttpResponse::Ok()
.content_type("application/javascript; charset=utf-8")
.body(PLUGIN_MODEL),
"BarWidget.qml" => HttpResponse::Ok()
.content_type("text/plain; charset=utf-8")
.body(PLUGIN_BAR_WIDGET),
_ => HttpResponse::NotFound().body("Plugin file not found"),
}
}
pub async fn omarchy_plugin_config(
req: HttpRequest,
session_store: web::Data<SessionStore>,
query: web::Query<DownloadQuery>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized().finish();
}
let server_url = resolve_server_url(&req, query.url.as_deref());
let config_json = serde_json::json!({
"name": "notify",
"url": server_url,
"pin": config.pin,
"api_key": config.pin,
"active": "notify"
});
let body = match serde_json::to_string_pretty(&config_json) {
Ok(s) => s + "\n",
Err(e) => {
log::error!("Failed to serialize plugin config: {e}");
return HttpResponse::InternalServerError().finish();
}
};
HttpResponse::Ok()
.content_type("application/json; charset=utf-8")
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(body)
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header::HeaderValue;
use actix_web::{App, cookie::Cookie, test as aw_test};
fn test_config() -> Config {
Config::new(
8080,
HeaderValue::from_static(crate::config::DEFAULT_CACHE_CONTROL),
"notify.db".to_string(),
false,
"123456".to_string(),
)
}
#[actix_web::test]
async fn test_install_unauthenticated_returns_guard() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.service(install_page),
)
.await;
let req = aw_test::TestRequest::get().uri("/install").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("class=\"otp\""));
assert!(body_str.contains("Unlock"));
}
#[actix_web::test]
async fn test_install_authenticated_returns_content() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie = Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.service(install_page),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/install")
.cookie(cookie)
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
// Sections
assert!(body_str.contains("Omarchy Plugin"));
assert!(body_str.contains("macOS App"));
assert!(body_str.contains("Linux App"));
assert!(body_str.contains("Ready to install"));
assert!(body_str.contains("Notify.app"));
// Script install command with pin
assert!(body_str.contains("curl -fsSL"));
assert!(body_str.contains("/install/omarchy.sh?pin=123456"));
assert!(body_str.contains("/install/mac.sh?pin=123456"));
assert!(body_str.contains("/install/linux.sh?pin=123456"));
assert!(body_str.contains("Notify-linux-x86_64.AppImage?pin=123456"));
assert!(body_str.contains("Notify-linux-x86_64.deb?pin=123456"));
assert!(body_str.contains("View macOS Installer Script"));
// Navigation
assert!(body_str.contains("class=\"navbar"));
assert!(body_str.contains("Back to notifications"));
assert!(body_str.contains("Debug"));
assert!(!body_str.contains("Export config"));
assert!(!body_str.contains("download=\"notify.json\""));
assert!(!body_str.contains("omarchy-notify-plugin.tar.gz"));
}
#[test]
fn test_build_omarchy_install_script_contains_files_and_config() {
let server_url = "http://192.168.1.100:9000";
let pin = "secret_pin_42";
let script = build_omarchy_install_script(server_url, pin);
assert!(script.starts_with("#!/usr/bin/env bash"));
assert!(
script.contains("SERVER_URL=\"${NOTIFY_SERVER_URL:-\"http://192.168.1.100:9000\"}\"")
);
assert!(script.contains("PIN=\"${NOTIFY_PIN:-\"secret_pin_42\"}\""));
// Plugin files downloaded
assert!(script.contains("manifest.json"));
assert!(script.contains("Service.qml"));
assert!(script.contains("Panel.qml"));
assert!(script.contains("Model.js"));
assert!(script.contains("BarWidget.qml"));
// Direct file download URL
assert!(script.contains("/install/omarchy/plugin/${file}?pin=${PIN}"));
// Config setup
assert!(script.contains("/install/omarchy/config?pin=${PIN}"));
assert!(script.contains("notify.json"));
// Omarchy activation
assert!(script.contains("omarchy-restart-shell"));
assert!(script.contains("omarchy plugin enable notify.desktop"));
}
#[actix_web::test]
async fn test_omarchy_installer_script_endpoint_unauthorized() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(App::new().app_data(config).app_data(session_store).route(
"/install/omarchy.sh",
web::get().to(omarchy_installer_script),
))
.await;
let req = aw_test::TestRequest::get()
.uri("/install/omarchy.sh")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn test_omarchy_installer_script_endpoint_authorized_with_pin() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(App::new().app_data(config).app_data(session_store).route(
"/install/omarchy.sh",
web::get().to(omarchy_installer_script),
))
.await;
let req = aw_test::TestRequest::get()
.uri("/install/omarchy.sh?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/x-shellscript; charset=utf-8"
);
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.starts_with("#!/usr/bin/env bash"));
assert!(body_str.contains("PIN=\"${NOTIFY_PIN:-\"123456\"}\""));
assert!(body_str.contains("omarchy plugin enable notify.desktop"));
}
#[actix_web::test]
async fn test_omarchy_plugin_file_endpoint() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(App::new().app_data(config).app_data(session_store).route(
"/install/omarchy/plugin/{filename:.*}",
web::get().to(omarchy_plugin_file),
))
.await;
// Unauthorized request
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/plugin/manifest.json")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
// Authorized request for manifest.json
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/plugin/manifest.json?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("\"id\": \"notify.desktop\""));
// Authorized request for unknown file
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/plugin/unknown.txt?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn test_omarchy_plugin_config_endpoint() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(App::new().app_data(config).app_data(session_store).route(
"/install/omarchy/config",
web::get().to(omarchy_plugin_config),
))
.await;
// Unauthorized request
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/config")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
// Authorized request
let req = aw_test::TestRequest::get()
.uri("/install/omarchy/config?pin=123456")
.insert_header(("Host", "10.0.0.1:8080"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["name"], "notify");
assert_eq!(parsed["pin"], "123456");
assert_eq!(parsed["api_key"], "123456");
assert_eq!(parsed["url"], "http://10.0.0.1:8080");
assert_eq!(parsed["active"], "notify");
}
#[test]
fn test_build_mac_install_script_contains_download_and_config() {
let script = build_mac_install_script("http://192.168.1.50:8080", "secretpin");
assert!(script.starts_with("#!/usr/bin/env bash"));
assert!(script.contains("Notify-mac.zip"));
assert!(script.contains("Notify.app"));
assert!(script.contains("http://192.168.1.50:8080"));
assert!(script.contains("secretpin"));
assert!(script.contains("/Applications/Notify.app"));
}
#[test]
fn test_build_linux_install_script_contains_download_and_desktop_entry() {
let script = build_linux_install_script("http://192.168.1.50:8080", "secretpin");
assert!(script.starts_with("#!/usr/bin/env bash"));
assert!(script.contains("Notify-linux-x86_64.AppImage"));
assert!(script.contains("notify.desktop"));
assert!(script.contains("http://192.168.1.50:8080"));
assert!(script.contains("secretpin"));
assert!(script.contains(r#"APP_DIR="${XDG_DATA_HOME:-"${HOME}/.local/share"}/notify""#));
let syntax_check = std::process::Command::new("bash")
.args(["-n", "-c", &script])
.output()
.expect("bash should be available to validate the Linux installer");
assert!(
syntax_check.status.success(),
"Linux installer has invalid shell syntax: {}",
String::from_utf8_lossy(&syntax_check.stderr)
);
}
#[actix_web::test]
async fn test_mac_installer_script_endpoint() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/install/mac.sh", web::get().to(mac_installer_script)),
)
.await;
// Unauth
let req = aw_test::TestRequest::get()
.uri("/install/mac.sh")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
// Auth
let req = aw_test::TestRequest::get()
.uri("/install/mac.sh?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let script = String::from_utf8(body.to_vec()).unwrap();
assert!(script.contains("Notify-mac.zip"));
}
#[actix_web::test]
async fn test_linux_installer_script_endpoint() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/install/linux.sh", web::get().to(linux_installer_script)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/install/linux.sh")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
let req = aw_test::TestRequest::get()
.uri("/install/linux.sh?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = aw_test::read_body(resp).await;
let script = String::from_utf8(body.to_vec()).unwrap();
assert!(script.contains("Notify-linux-x86_64.AppImage"));
assert!(script.contains("notify.desktop"));
}
#[actix_web::test]
async fn test_download_file_endpoint() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(SessionStore::in_memory());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let filename = format!("test-download-{}-{timestamp}.zip", std::process::id());
let fixture_path = get_dist_dir().join(&filename);
std::fs::write(&fixture_path, b"test download").unwrap();
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/download/{filename:.*}", web::get().to(download_file)),
)
.await;
// Unauth
let req = aw_test::TestRequest::get()
.uri("/download/Notify-mac.zip")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
// Non-existent file
let req = aw_test::TestRequest::get()
.uri("/download/nonexistent.bin?pin=123456")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
// Download a fixture archive.
let req = aw_test::TestRequest::get()
.uri(&format!("/download/{filename}?pin=123456"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(actix_web::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"application/zip"
);
std::fs::remove_file(fixture_path).unwrap();
}
}