Launcher: write Windows shortcuts through the shell

`shortcut::lnk` lays a Shell Link out from the format's specification, which is
what lets the tests check it on every system, but it writes no target ID list,
and the shell reads the target from that list. The shortcuts #6416 installs
showed an empty target in their properties and a double click started nothing;
the instance shortcuts from Home > Shortcut are laid out the same way.

`shortcut::write_windows_link` creates the link with `IShellLinkW` and saves it
with `IPersistFile`, as every installer does. `make_in` uses it on Windows and
keeps the byte layout for the other systems and for the tests.

Two tests hold it: one reads the target back with the shell's own `GetPath`
(the bug was that it read empty), and one checks that an instance's arguments
survive the shell's write, so `plays` still finds the shortcut at uninstall.

Co-Authored-By: OpenCode <[email protected]>
This commit is contained in:
jaszkusandOpenCode committed 2026-11-02 11:00:07 +01:00
1 parent b82089c77d
commit ea0c475c02
5 files changed
+124 -23

No files matched your search

+1
View File
@@ -2714,6 +2714,7 @@ dependencies = [
"toml 1.1.8+spec-1.1.0",
"trash",
"ureq",
"windows 0.62.2",
"windows-sys 0.61.2",
"winreg",
"winresource",
+3
View File
@@ -86,6 +86,9 @@ raw-window-handle = "0.6"
keyring = { version = "3", features = ["windows-native"] }
# The computer's memory, the end of the Launch panel's memory slider; already in the lockfile.
windows-sys = { version = "0.61", features = ["Win32_System_SystemInformation"] }
# Shortcuts written by the shell itself (shortcut::write_windows_link): the hand-laid Shell Link has
# no target ID list, so Explorer shows an empty target and starts nothing.
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_UI_Shell"] }
# Per-user setup: the Uninstall entry and the launcher's own key under HKCU (installer.rs).
winreg = "0.56"
+36 -13
View File
@@ -165,13 +165,7 @@ fn desktop_link() -> Option<PathBuf> {
}
fn write_link(path: &Path, exe: &Path) -> io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let bytes = shortcut::lnk(&exe.to_string_lossy(), "", APP_NAME);
let temp = path.with_extension("horizon-tmp");
fs::write(&temp, bytes)?;
fs::rename(&temp, path)
shortcut::write_windows_link(path, exe, "", APP_NAME).map_err(io::Error::other)
}
/// Whether the shortcut file names `exe` (its path, as the Shell Link keeps it, in UTF-16).
@@ -505,16 +499,45 @@ mod tests {
);
}
/// What the shell itself reads as the shortcut's target: the field Explorer starts from.
fn target_of(link: &Path) -> String {
use windows::core::{Interface, HSTRING};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, IPersistFile, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED, STGM_READ,
};
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
// SAFETY: COM calls on interfaces made and dropped here, on this test's own thread.
unsafe {
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
let shell: IShellLinkW =
CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).unwrap();
let file: IPersistFile = shell.cast().unwrap();
file.Load(&HSTRING::from(link.as_os_str()), STGM_READ)
.unwrap();
let mut buf = [0u16; 520];
shell.GetPath(&mut buf, std::ptr::null_mut(), 0).unwrap();
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..end])
}
}
#[test]
fn a_shortcut_points_at_the_launcher_it_was_made_for() {
fn the_shell_reads_the_target_of_the_shortcut_it_gets() {
let dir = std::env::temp_dir().join(format!("horizon-link-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let _ = fs::remove_dir_all(&dir);
let exe = dir.join("Horizon Launcher").join("horizon-launcher.exe");
fs::create_dir_all(exe.parent().unwrap()).unwrap();
fs::write(&exe, b"x").unwrap();
let link = dir.join("Horizon Launcher.lnk");
let exe = Path::new(
r"C:\Users\player\AppData\Local\Programs\Horizon Launcher\horizon-launcher.exe",
write_link(&link, &exe).unwrap();
// The bug this guards: a link whose target the shell read as empty, so a double click did nothing.
assert_eq!(
target_of(&link).to_lowercase(),
exe.to_string_lossy().to_lowercase()
);
fs::write(&link, shortcut::lnk(&exe.to_string_lossy(), "", APP_NAME)).unwrap();
assert!(points_to(&link, exe));
// The uninstaller finds its shortcuts by the program's path inside them.
assert!(points_to(&link, &exe));
assert!(!points_to(
&link,
Path::new(r"C:\Other\horizon-launcher.exe")
+55 -9
View File
@@ -246,6 +246,51 @@ pub fn lnk(exe: &str, args: &str, comment: &str) -> Vec<u8> {
out
}
/// A Windows shortcut written by the shell itself (`IShellLinkW`), as Explorer and every installer
/// make one. `lnk` above lays the bytes out from the format's specification, which tests can check
/// anywhere, but it has no target ID list, and the shell reads the target from that list: Explorer
/// shows an empty target and starts nothing. So on Windows this writes the shortcuts that are used.
/// The icon is the program's own, which is also how an uninstall finds shortcuts made for it.
#[cfg(windows)]
pub fn write_windows_link(
path: &Path,
exe: &Path,
args: &str,
comment: &str,
) -> Result<(), String> {
use windows::core::{Interface, HSTRING};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
// SAFETY: plain COM calls on interfaces created and dropped here; COM is initialised for this
// thread first and released after, when this call is the one that initialised it.
unsafe {
let init = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
let made = (|| -> windows::core::Result<()> {
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
link.SetPath(&HSTRING::from(exe.as_os_str()))?;
link.SetArguments(&HSTRING::from(args))?;
if let Some(dir) = exe.parent() {
link.SetWorkingDirectory(&HSTRING::from(dir.as_os_str()))?;
}
link.SetIconLocation(&HSTRING::from(exe.as_os_str()), 0)?;
link.SetDescription(&HSTRING::from(comment))?;
let file: IPersistFile = link.cast()?;
file.Save(&HSTRING::from(path.as_os_str()), true)
})();
if init.is_ok() {
CoUninitialize();
}
made.map_err(|e| format!("{}: {e}", path.display()))
}
}
/// Whether the shortcut at `path` plays the instance `id`.
fn plays(path: &Path, id: &str) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
@@ -323,15 +368,16 @@ pub fn make_in(
write(&path.join(inside), &bytes, program)?;
}
}
System::Windows => write(
&path,
&lnk(
&exe.to_string_lossy(),
&windows_args(id),
&format!("Play {name} in Horizon"),
),
false,
)?,
System::Windows => {
let args = windows_args(id);
let comment = format!("Play {name} in Horizon");
// On Windows the shell writes the shortcut (`write_windows_link`); elsewhere, and in
// tests for the other systems, it is laid out from the format.
#[cfg(windows)]
write_windows_link(&path, exe, &args, &comment)?;
#[cfg(not(windows))]
write(&path, &lnk(&exe.to_string_lossy(), &args, &comment), false)?;
}
}
Ok(made)
}
+29 -1
View File
@@ -128,6 +128,28 @@ fn a_windows_shortcut_is_a_shell_link_to_the_launcher() {
);
}
/// The shell-written shortcut keeps the instance's arguments, so an uninstall finds it: `plays`
/// reads them from the file's Unicode strings.
#[cfg(windows)]
#[test]
fn the_shell_written_link_keeps_the_instance_it_plays() {
let root = scratch("plays");
let exe = root.join("Horizon Launcher").join("horizon-launcher.exe");
fs::create_dir_all(exe.parent().unwrap()).unwrap();
fs::write(&exe, b"x").unwrap();
let link = root.join("Hardcore (Horizon).lnk");
write_windows_link(
&link,
&exe,
&windows_args("hardcore"),
"Play Hardcore in Horizon",
)
.unwrap();
assert!(plays(&link, "hardcore"));
assert!(!plays(&link, "another"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn shortcuts_are_made_found_renamed_and_removed() {
let root = scratch("make");
@@ -139,7 +161,13 @@ fn shortcuts_are_made_found_renamed_and_removed() {
let bin = trash.clone();
let trash_fn = move |p: &Path| desktop_seams::to_folder(p, &bin);
let t: Trash = &trash_fn;
let exe = Path::new("/opt/horizon-launcher");
// A path the shell accepts on the system running the test: Windows writes its shortcuts through
// IShellLinkW, which refuses a Unix path.
let exe = if cfg!(windows) {
Path::new(r"C:\Horizon\horizon-launcher.exe")
} else {
Path::new("/opt/horizon-launcher")
};
for system in [System::Linux, System::Mac, System::Windows] {
let made = make_in(
system,