use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tracing::{debug, info, warn};
use kanade_shared::wire::EffectiveConfig;
const DEFAULT_DISPLAY_NAME: &str = kanade_shared::DEFAULT_CLIENT_DISPLAY_NAME;
const AUMID: &str = "com.yukimemi.kanade-client";
const LEGACY_LNK_NAME: &str = "Kanade Client.lnk";
#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq)]
struct ShortcutState {
display_name: String,
lnk_path: String,
}
pub fn spawn(cfg_rx: watch::Receiver<EffectiveConfig>) {
tokio::spawn(sync_loop(cfg_rx));
}
fn desired_name(cfg: &EffectiveConfig) -> String {
cfg.client_display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| DEFAULT_DISPLAY_NAME.to_string())
}
const PENDING_RETRY_INTERVAL: Duration = Duration::from_secs(300);
async fn sync_loop(mut rx: watch::Receiver<EffectiveConfig>) {
let mut last_applied: Option<String> = None;
loop {
let pending;
let desired = desired_name(&rx.borrow());
if last_applied.as_deref() == Some(desired.as_str()) {
pending = false;
} else {
match ensure_shortcut(&desired).await {
Ok(SyncOutcome::Synced) => {
info!(display_name = %desired, "client Start-Menu shortcut synced");
last_applied = Some(desired);
pending = false;
}
Ok(SyncOutcome::AlreadyCurrent) => {
last_applied = Some(desired);
pending = false;
}
Ok(SyncOutcome::NoClient) => {
debug!(display_name = %desired, "client exe absent — shortcut sync skipped");
pending = true;
}
Err(e) => {
warn!(error = %e, display_name = %desired, "client shortcut sync failed");
pending = true;
}
}
}
if pending {
match tokio::time::timeout(PENDING_RETRY_INTERVAL, rx.changed()).await {
Err(_elapsed) => continue, Ok(Ok(())) => continue, Ok(Err(_)) => return, }
} else if rx.changed().await.is_err() {
return;
}
}
}
enum SyncOutcome {
Synced,
AlreadyCurrent,
NoClient,
}
async fn ensure_shortcut(display_name: &str) -> std::io::Result<SyncOutcome> {
let programs = start_menu_programs_dir();
let desired_lnk = programs.join(format!("{}.lnk", sanitize_lnk_stem(display_name)));
let legacy_lnk = programs.join(LEGACY_LNK_NAME);
let state_path = state_file_path();
let prev = read_state(&state_path);
let already_current = prev
.as_ref()
.is_some_and(|s| s.display_name == display_name && s.lnk_path == path_str(&desired_lnk))
&& desired_lnk.exists()
&& !legacy_lnk.exists();
if already_current {
return Ok(SyncOutcome::AlreadyCurrent);
}
let exe = client_exe_path();
if !exe.exists() {
return Ok(SyncOutcome::NoClient);
}
let prev_lnk = prev
.as_ref()
.map(|s| s.lnk_path.clone())
.unwrap_or_default();
run_shortcut_script(display_name, &exe, &desired_lnk, &prev_lnk, &legacy_lnk).await?;
write_state(
&state_path,
&ShortcutState {
display_name: display_name.to_string(),
lnk_path: path_str(&desired_lnk),
},
);
Ok(SyncOutcome::Synced)
}
fn start_menu_programs_dir() -> PathBuf {
let program_data = std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
program_data.join(r"Microsoft\Windows\Start Menu\Programs")
}
fn client_exe_path() -> PathBuf {
let program_files = std::env::var_os("ProgramFiles")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
program_files.join(r"Kanade\kanade-client.exe")
}
fn state_file_path() -> PathBuf {
kanade_shared::default_paths::data_dir().join("client-shortcut.json")
}
fn sanitize_lnk_stem(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
let trimmed = cleaned.trim_end_matches(['.', ' ']).trim();
if trimmed.is_empty() {
return DEFAULT_DISPLAY_NAME.to_string();
}
if is_reserved_device_name(trimmed) {
return format!("{trimmed}_");
}
trimmed.to_string()
}
fn is_reserved_device_name(stem: &str) -> bool {
let base = stem.split('.').next().unwrap_or(stem).to_ascii_uppercase();
matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((base.starts_with("COM") || base.starts_with("LPT"))
&& base.len() == 4
&& matches!(base.as_bytes()[3], b'1'..=b'9'))
}
fn path_str(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn read_state(path: &Path) -> Option<ShortcutState> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn write_state(path: &Path, state: &ShortcutState) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match serde_json::to_vec_pretty(state) {
Ok(bytes) => {
if let Err(e) = std::fs::write(path, bytes) {
warn!(error = %e, path = %path.display(), "write client-shortcut state failed");
}
}
Err(e) => warn!(error = %e, "encode client-shortcut state failed"),
}
}
async fn run_shortcut_script(
display_name: &str,
exe: &Path,
desired_lnk: &Path,
prev_lnk: &str,
legacy_lnk: &Path,
) -> std::io::Result<()> {
use tokio::process::Command;
let script_path =
std::env::temp_dir().join(format!("kanade-client-shortcut-{}.ps1", std::process::id()));
std::fs::write(&script_path, SHORTCUT_PS1)?;
let mut cmd = Command::new("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(&script_path)
.env("KSC_DISPLAY_NAME", display_name)
.env("KSC_EXE_PATH", exe)
.env("KSC_AUMID", AUMID)
.env("KSC_LNK_PATH", desired_lnk)
.env("KSC_PREV_LNK", prev_lnk)
.env("KSC_LEGACY_LNK", legacy_lnk)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
cmd.creation_flags(0x0800_0000);
let result = cmd.output().await;
let _ = std::fs::remove_file(&script_path);
let out = result?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(std::io::Error::other(format!(
"shortcut script exited {}: {}",
out.status,
stderr.trim()
)));
}
Ok(())
}
const SHORTCUT_PS1: &str = r#"
$ErrorActionPreference = 'Stop'
$display = $env:KSC_DISPLAY_NAME
$exe = $env:KSC_EXE_PATH
$aumid = $env:KSC_AUMID
$lnkPath = $env:KSC_LNK_PATH
# Remove stale shortcuts (the previous custom name + the legacy fixed
# name) so the Start-Menu shows exactly one entry under the current
# name. Never delete the target we're about to (re)create.
# -LiteralPath: the path embeds the operator's display name, which can
# contain PowerShell wildcard-class chars (`[ ] * ?`); without it
# Test-Path/Remove-Item would glob and could match/delete the wrong file.
foreach ($stale in @($env:KSC_PREV_LNK, $env:KSC_LEGACY_LNK)) {
if ($stale -and ($stale -ne $lnkPath) -and (Test-Path -LiteralPath $stale)) {
Remove-Item -LiteralPath $stale -Force -ErrorAction SilentlyContinue
}
}
# 1) Basic shortcut (target/description) via WScript.Shell. The icon is
# the exe's own embedded icon (the baton mark), so no IconLocation.
$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut($lnkPath)
$lnk.TargetPath = $exe
$lnk.Description = $display
$lnk.Save()
# 2) Stamp System.AppUserModel.ID via IPropertyStore — the COM interop
# WScript.Shell can't reach. Without this the AUMID->shortcut mapping
# is missing and WinRT toasts silently don't render.
if (-not ([System.Management.Automation.PSTypeName]'Kanade.ShortcutAumid').Type) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
namespace Kanade {
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct PropertyKey { public Guid fmtid; public uint pid; }
[StructLayout(LayoutKind.Explicit, Size = 16)]
public struct PropVariant {
[FieldOffset(0)] public ushort vt;
[FieldOffset(8)] public IntPtr p;
}
[ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IPropertyStore {
int GetCount(out uint c);
int GetAt(uint i, out PropertyKey k);
int GetValue(ref PropertyKey k, out PropVariant v);
int SetValue(ref PropertyKey k, ref PropVariant v);
int Commit();
}
[ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IPersistFile {
int GetClassID(out Guid id);
int IsDirty();
int Load([MarshalAs(UnmanagedType.LPWStr)] string f, int mode);
int Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool remember);
int SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
int GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
}
public static class ShortcutAumid {
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PropVariant pvar);
public static void Set(string lnk, string aumid) {
Type slType = Type.GetTypeFromCLSID(new Guid("00021401-0000-0000-C000-000000000046"));
object sl = Activator.CreateInstance(slType);
PropVariant pv = new PropVariant();
try {
((IPersistFile)sl).Load(lnk, 2); // STGM_READWRITE so Save() works
IPropertyStore ps = (IPropertyStore)sl;
// PKEY_AppUserModel_ID = {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}, 5
PropertyKey key = new PropertyKey {
fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5
};
pv.vt = 31 /*VT_LPWSTR*/;
pv.p = Marshal.StringToCoTaskMemUni(aumid);
ps.SetValue(ref key, ref pv);
ps.Commit();
((IPersistFile)sl).Save(lnk, true);
} finally {
PropVariantClear(ref pv);
Marshal.ReleaseComObject(sl);
}
}
}
}
'@
}
[Kanade.ShortcutAumid]::Set($lnkPath, $aumid)
Write-Output 'ok'
"#;
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(name: Option<&str>) -> EffectiveConfig {
EffectiveConfig {
client_display_name: name.map(str::to_owned),
..EffectiveConfig::builtin_defaults()
}
}
#[test]
fn desired_name_falls_back_to_default_when_unset() {
assert_eq!(desired_name(&cfg_with(None)), DEFAULT_DISPLAY_NAME);
}
#[test]
fn desired_name_uses_configured_value() {
assert_eq!(
desired_name(&cfg_with(Some("社内端末ツール"))),
"社内端末ツール"
);
}
#[test]
fn desired_name_treats_blank_as_unset() {
assert_eq!(desired_name(&cfg_with(Some(" "))), DEFAULT_DISPLAY_NAME);
}
#[test]
fn sanitize_lnk_stem_replaces_reserved_chars() {
assert_eq!(
sanitize_lnk_stem(r#"a/b\c:d*e?f"g<h>i|j"#),
"a_b_c_d_e_f_g_h_i_j"
);
assert_eq!(
sanitize_lnk_stem("端末管理支援ツール"),
"端末管理支援ツール"
);
}
#[test]
fn sanitize_lnk_stem_trims_trailing_dots_and_spaces() {
assert_eq!(sanitize_lnk_stem("ツール .. "), "ツール");
}
#[test]
fn sanitize_lnk_stem_suffixes_reserved_device_names() {
assert_eq!(sanitize_lnk_stem("CON"), "CON_");
assert_eq!(sanitize_lnk_stem("nul"), "nul_");
assert_eq!(sanitize_lnk_stem("Com1"), "Com1_");
assert_eq!(sanitize_lnk_stem("LPT9"), "LPT9_");
assert_eq!(sanitize_lnk_stem("CON.foo"), "CON.foo_");
assert_eq!(sanitize_lnk_stem("CONSOLE"), "CONSOLE");
assert_eq!(sanitize_lnk_stem("COM10"), "COM10");
assert_eq!(sanitize_lnk_stem("COM0"), "COM0");
}
#[test]
fn sanitize_lnk_stem_empty_result_falls_back_to_default() {
assert_eq!(sanitize_lnk_stem(" ... "), DEFAULT_DISPLAY_NAME);
}
#[test]
fn shortcut_state_round_trips() {
let s = ShortcutState {
display_name: "端末管理支援ツール".into(),
lnk_path: r"C:\ProgramData\...\端末管理支援ツール.lnk".into(),
};
let json = serde_json::to_vec(&s).unwrap();
let back: ShortcutState = serde_json::from_slice(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn default_name_matches_client_default() {
assert_eq!(DEFAULT_DISPLAY_NAME, "端末管理支援ツール");
}
}