summaryrefslogtreecommitdiffhomepage
path: root/desktop
diff options
context:
space:
mode:
authorOskar <oskar@mullvad.net>2025-02-10 09:28:35 +0100
committerDavid Lönnhager <david.l@mullvad.net>2025-02-11 15:37:22 +0100
commite7e7252fc5e765bf971ff502a4f81b1cb20c92ad (patch)
treeb7381005a33b7a43214fce4572262629141a6521 /desktop
parente93aba7e9d8c7210aac9b6d09c0067823bffc7e9 (diff)
downloadmullvadvpn-e7e7252fc5e765bf971ff502a4f81b1cb20c92ad.tar.xz
mullvadvpn-e7e7252fc5e765bf971ff502a4f81b1cb20c92ad.zip
Implement shortcut parsing
Co-authored-by: David Lönnhager <david.l@mullvad.net> Co-authored-by: Markus Pettersson <markus.pettersson@mullvad.net> Co-authored-by: Joakim Hulthe <joakim.hulthe@mullvad.net>
Diffstat (limited to 'desktop')
-rw-r--r--desktop/package-lock.json21
-rw-r--r--desktop/packages/mullvad-vpn/gulpfile.js12
-rw-r--r--desktop/packages/mullvad-vpn/package.json1
-rw-r--r--desktop/packages/mullvad-vpn/src/main/index.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/main/windows-split-tunneling.ts47
-rw-r--r--desktop/packages/mullvad-vpn/tasks/distribution.js5
-rw-r--r--desktop/packages/mullvad-vpn/tasks/scripts.js6
-rw-r--r--desktop/packages/win-shortcuts/.gitignore9
-rw-r--r--desktop/packages/win-shortcuts/Cargo.toml21
-rw-r--r--desktop/packages/win-shortcuts/README.md19
-rw-r--r--desktop/packages/win-shortcuts/eslint.config.mjs3
-rw-r--r--desktop/packages/win-shortcuts/package.json36
-rw-r--r--desktop/packages/win-shortcuts/src/index.cts18
-rw-r--r--desktop/packages/win-shortcuts/src/index.mts3
-rw-r--r--desktop/packages/win-shortcuts/src/load.cts11
-rw-r--r--desktop/packages/win-shortcuts/tsconfig.json9
-rw-r--r--desktop/packages/win-shortcuts/win-shortcuts-rs/lib.rs191
17 files changed, 404 insertions, 15 deletions
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index fc1c088646..de1e261588 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -14219,6 +14219,10 @@
"string-width": "^1.0.2 || 2"
}
},
+ "node_modules/win-shortcuts": {
+ "resolved": "packages/win-shortcuts",
+ "link": true
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -14669,7 +14673,8 @@
"redux": "^4.2.0",
"simple-plist": "^1.3.1",
"sprintf-js": "^1.1.2",
- "styled-components": "^6.1.13"
+ "styled-components": "^6.1.13",
+ "win-shortcuts": "0.0.0"
},
"devDependencies": {
"@playwright/test": "^1.41.1",
@@ -14720,6 +14725,13 @@
"dependencies": {
"@neon-rs/load": "^0.1.73"
}
+ },
+ "packages/win-shortcuts": {
+ "version": "0.0.0",
+ "license": "GPL-3.0",
+ "dependencies": {
+ "@neon-rs/load": "^0.1.73"
+ }
}
},
"dependencies": {
@@ -22607,6 +22619,7 @@
"tsc-watch": "^5.0.3",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0",
+ "win-shortcuts": "0.0.0",
"xvfb-maybe": "^0.2.1"
}
},
@@ -25870,6 +25883,12 @@
"string-width": "^1.0.2 || 2"
}
},
+ "win-shortcuts": {
+ "version": "file:packages/win-shortcuts",
+ "requires": {
+ "@neon-rs/load": "^0.1.73"
+ }
+ },
"word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/desktop/packages/mullvad-vpn/gulpfile.js b/desktop/packages/mullvad-vpn/gulpfile.js
index 02375032f4..529b05f9c4 100644
--- a/desktop/packages/mullvad-vpn/gulpfile.js
+++ b/desktop/packages/mullvad-vpn/gulpfile.js
@@ -18,8 +18,18 @@ task('set-prod-env', function (done) {
task('clean', function (done) {
fs.rm('./build', { recursive: true, force: true }, done);
});
+task('build-proto', scripts.buildProto);
+task(
+ 'develop',
+ series(
+ 'clean',
+ 'set-dev-env',
+ scripts.buildNseventforwarder,
+ scripts.buildWinShortcuts,
+ watch.start,
+ ),
+);
task('build', series('clean', 'set-prod-env', assets.copyAll, scripts.build));
-task('develop', series('clean', 'set-dev-env', scripts.buildNseventforwarder, watch.start));
task('pack-win', series('build', dist.packWin));
task('pack-linux', series('build', dist.packLinux));
task('pack-mac', series('build', dist.packMac));
diff --git a/desktop/packages/mullvad-vpn/package.json b/desktop/packages/mullvad-vpn/package.json
index 056b0be93f..0963f79914 100644
--- a/desktop/packages/mullvad-vpn/package.json
+++ b/desktop/packages/mullvad-vpn/package.json
@@ -25,6 +25,7 @@
"react-redux": "^7.2.9",
"react-router": "^5.3.4",
"redux": "^4.2.0",
+ "win-shortcuts": "0.0.0",
"simple-plist": "^1.3.1",
"sprintf-js": "^1.1.2",
"styled-components": "^6.1.13"
diff --git a/desktop/packages/mullvad-vpn/src/main/index.ts b/desktop/packages/mullvad-vpn/src/main/index.ts
index 2186168379..a80241f527 100644
--- a/desktop/packages/mullvad-vpn/src/main/index.ts
+++ b/desktop/packages/mullvad-vpn/src/main/index.ts
@@ -829,7 +829,12 @@ class ApplicationMain
// If the applications is a string (path) it's an application picked with the file picker
// that we want to add to the list of additional applications.
if (typeof application === 'string') {
- const executablePath = await splitTunneling!.resolveExecutablePath(application);
+ let executablePath;
+ try {
+ executablePath = await splitTunneling!.resolveExecutablePath(application);
+ } catch {
+ return;
+ }
this.settings.gui.addBrowsedForSplitTunnelingApplications(executablePath);
await splitTunneling!.addApplicationPathToCache(application);
await this.daemonRpc.addSplitTunnelingApplication(executablePath);
diff --git a/desktop/packages/mullvad-vpn/src/main/windows-split-tunneling.ts b/desktop/packages/mullvad-vpn/src/main/windows-split-tunneling.ts
index 7ae73827d0..4f1df5660a 100644
--- a/desktop/packages/mullvad-vpn/src/main/windows-split-tunneling.ts
+++ b/desktop/packages/mullvad-vpn/src/main/windows-split-tunneling.ts
@@ -1,7 +1,10 @@
-import { app, shell } from 'electron';
+import { app } from 'electron';
import fs from 'fs';
import path from 'path';
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { readShortcut } = require('win-shortcuts');
+
import {
ISplitTunnelingApplication,
ISplitTunnelingAppListRetriever,
@@ -114,7 +117,12 @@ export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingApp
public resolveExecutablePath(providedPath: string): Promise<string> {
if (path.extname(providedPath) === '.lnk') {
- return Promise.resolve(shell.readShortcutLink(path.resolve(providedPath)).target);
+ const target = this.tryReadShortcut(path.resolve(providedPath));
+ if (target) {
+ return Promise.resolve(target);
+ } else {
+ return Promise.reject('Failed to resolve shortcut');
+ }
}
return Promise.resolve(providedPath);
@@ -124,12 +132,14 @@ export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingApp
public async addApplicationPathToCache(applicationPath: string): Promise<void> {
const parsedPath = path.parse(applicationPath);
if (parsedPath.ext === '.lnk') {
- const shortcutDetiails = shell.readShortcutLink(path.resolve(applicationPath));
- this.additionalShortcuts.push({
- ...shortcutDetiails,
- name: path.parse(applicationPath).name,
- deletable: true,
- });
+ const target = this.tryReadShortcut(path.resolve(applicationPath));
+ if (target) {
+ this.additionalShortcuts.push({
+ target,
+ name: path.parse(applicationPath).name,
+ deletable: true,
+ });
+ }
} else {
await this.addApplicationToAdditionalShortcuts(applicationPath);
}
@@ -216,14 +226,14 @@ export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingApp
private resolveLinks(linkPaths: string[]): ShortcutDetails[] {
return linkPaths
.map((link) => {
- try {
+ const target = this.tryReadShortcut(path.resolve(link));
+ if (target) {
return {
- ...shell.readShortcutLink(path.resolve(link)),
+ target,
name: path.parse(link).name,
};
- } catch {
- return null;
}
+ return null;
})
.filter(
(shortcut): shortcut is ShortcutDetails =>
@@ -677,4 +687,17 @@ export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingApp
private alignDword(offset: number): number {
return Math.ceil(offset / 4) * 4;
}
+
+ private tryReadShortcut(appPath: string): string | null {
+ try {
+ return readShortcut(path.resolve(appPath));
+ } catch (e) {
+ if (e && typeof e === 'object' && 'message' in e) {
+ log.error(`Failed to read .lnk shortcut for ${appPath}.`, e.message);
+ } else {
+ log.error(`Failed to read .lnk shortcut for ${appPath}.`);
+ }
+ return null;
+ }
+ }
}
diff --git a/desktop/packages/mullvad-vpn/tasks/distribution.js b/desktop/packages/mullvad-vpn/tasks/distribution.js
index 8ab8a35882..9965db3ccb 100644
--- a/desktop/packages/mullvad-vpn/tasks/distribution.js
+++ b/desktop/packages/mullvad-vpn/tasks/distribution.js
@@ -62,6 +62,7 @@ function newConfig() {
'!node_modules/grpc-tools',
'!node_modules/@types',
'!node_modules/nseventforwarder/debug',
+ '!node_modules/win-shortcuts/debug',
],
// Make sure that all files declared in "extraResources" exists and abort if they don't.
@@ -300,12 +301,16 @@ async function packWin() {
process.env.SETUP_SUBDIR = '.';
process.env.TARGET_SUBDIR = 'x86_64-pc-windows-msvc';
process.env.DIST_SUBDIR = '';
+
+ execFileSync('npm', ['-w', 'win-shortcuts', 'run', 'build-x86']);
break;
case 'arm64':
process.env.TARGET_TRIPLE = 'aarch64-pc-windows-msvc';
process.env.SETUP_SUBDIR = 'aarch64-pc-windows-msvc';
process.env.TARGET_SUBDIR = 'aarch64-pc-windows-msvc';
process.env.DIST_SUBDIR = 'aarch64-pc-windows-msvc';
+
+ execFileSync('npm', ['-w', 'win-shortcuts', 'run', 'build-arm']);
break;
default:
throw new Error('Invalid or unknown target (only one may be specified)');
diff --git a/desktop/packages/mullvad-vpn/tasks/scripts.js b/desktop/packages/mullvad-vpn/tasks/scripts.js
index 066ad60c6b..baa89736d2 100644
--- a/desktop/packages/mullvad-vpn/tasks/scripts.js
+++ b/desktop/packages/mullvad-vpn/tasks/scripts.js
@@ -117,12 +117,18 @@ function buildNseventforwarder(callback) {
}
}
+function buildWinShortcuts(callback) {
+ exec('npm -w win-shortcuts run build-debug', (err) => callback(err));
+}
+
compileScripts.displayName = 'compile-scripts';
buildNseventforwarder.displayName = 'build-nseventforwarder';
+buildWinShortcuts.displayName = 'build-win-shortcuts';
exports.build = series(
compileScripts,
parallel(makeBrowserifyPreload(false), makeBrowserifyRenderer(false)),
);
exports.buildNseventforwarder = buildNseventforwarder;
+exports.buildWinShortcuts = buildWinShortcuts;
exports.makeWatchCompiler = makeWatchCompiler;
diff --git a/desktop/packages/win-shortcuts/.gitignore b/desktop/packages/win-shortcuts/.gitignore
new file mode 100644
index 0000000000..ec4fda9fef
--- /dev/null
+++ b/desktop/packages/win-shortcuts/.gitignore
@@ -0,0 +1,9 @@
+target
+index.node
+**/node_modules
+**/.DS_Store
+npm-debug.log*
+lib
+*.log
+dist/
+debug/
diff --git a/desktop/packages/win-shortcuts/Cargo.toml b/desktop/packages/win-shortcuts/Cargo.toml
new file mode 100644
index 0000000000..ed8f3f43af
--- /dev/null
+++ b/desktop/packages/win-shortcuts/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "win-shortcuts"
+description = ""
+authors.workspace = true
+repository.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+exclude = ["index.node"]
+
+[lints]
+workspace = true
+
+[lib]
+crate-type = ["cdylib"]
+path = "win-shortcuts-rs/lib.rs"
+
+[target.'cfg(target_os = "windows")'.dependencies]
+neon = "1"
+windows = { version = "0.59.0", features = ["Win32", "Win32_UI", "Win32_UI_Shell", "Win32_System", "Win32_System_Com", "Win32_Storage_FileSystem"] }
+thiserror = { workspace = true }
diff --git a/desktop/packages/win-shortcuts/README.md b/desktop/packages/win-shortcuts/README.md
new file mode 100644
index 0000000000..b837b8e6a6
--- /dev/null
+++ b/desktop/packages/win-shortcuts/README.md
@@ -0,0 +1,19 @@
+# win-shortcuts
+
+## Building win-shortcuts
+
+Building win-shortcuts requires a [supported version of Node and Rust](https://github.com/neon-bindings/neon#platform-support).
+
+To run the build, run:
+
+```sh
+$ npm run build-debug
+```
+
+## Learn More
+
+Learn more about:
+
+- [Neon](https://neon-bindings.com).
+- [Rust](https://www.rust-lang.org).
+- [Node](https://nodejs.org).
diff --git a/desktop/packages/win-shortcuts/eslint.config.mjs b/desktop/packages/win-shortcuts/eslint.config.mjs
new file mode 100644
index 0000000000..9e16ad57ea
--- /dev/null
+++ b/desktop/packages/win-shortcuts/eslint.config.mjs
@@ -0,0 +1,3 @@
+import workspaceConfig from '../../eslint.config.mjs';
+
+export default [...workspaceConfig, { ignores: ['lib/'] }];
diff --git a/desktop/packages/win-shortcuts/package.json b/desktop/packages/win-shortcuts/package.json
new file mode 100644
index 0000000000..245c9ef179
--- /dev/null
+++ b/desktop/packages/win-shortcuts/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "win-shortcuts",
+ "version": "0.0.0",
+ "author": "Mullvad VPN",
+ "license": "GPL-3.0",
+ "description": "",
+ "main": "./lib/index.cjs",
+ "scripts": {
+ "cargo-build": "tsc && cargo build",
+ "build-debug": "npm run cargo-build && (test -d debug || mkdir debug) && cp ../../../target/debug/win_shortcuts.dll debug/index.node",
+ "build-arm": "npm run cargo-build -- --release --target aarch64-pc-windows-msvc && (test -d dist || mkdir dist) && (test -d dist/win32-arm64-msvc || mkdir \"dist/win32-arm64-msvc\") && cp ../../../target/aarch64-pc-windows-msvc/release/win_shortcuts.dll dist/win32-arm64-msvc/index.node",
+ "build-x86": "npm run cargo-build -- --release --target x86_64-pc-windows-msvc && (test -d dist || mkdir dist) && (test -d dist/win32-x64-msvc || mkdir \"dist/win32-x64-msvc\") && cp ../../../target/x86_64-pc-windows-msvc/release/win_shortcuts.dll dist/win32-x64-msvc/index.node",
+ "clean": "rm -rf debug; rm -rf dist",
+ "lint": "eslint .",
+ "lint-fix": "eslint --fix ."
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./lib/index.d.mts",
+ "default": "./lib/index.mjs"
+ },
+ "require": {
+ "types": "./lib/index.d.cts",
+ "default": "./lib/index.cjs"
+ }
+ }
+ },
+ "types": "./lib/index.d.cts",
+ "files": [
+ "lib/**/*.?({c,m}){t,j}s"
+ ],
+ "dependencies": {
+ "@neon-rs/load": "^0.1.73"
+ }
+}
diff --git a/desktop/packages/win-shortcuts/src/index.cts b/desktop/packages/win-shortcuts/src/index.cts
new file mode 100644
index 0000000000..717968cda4
--- /dev/null
+++ b/desktop/packages/win-shortcuts/src/index.cts
@@ -0,0 +1,18 @@
+// This module is the CJS entry point for the library.
+
+// The Rust addon.
+import * as addon from './load.cjs';
+
+// Use this declaration to assign types to the addon's exports,
+// which otherwise by default are `any`.
+declare module './load.cjs' {
+ function readShortcut(linkPath: string): string | null;
+}
+
+/**
+ * Return path for a shortcut.
+ * @param linkPath absolute path to a `.lnk`.
+ */
+export function readShortcut(linkPath: string): string | null {
+ return addon.readShortcut(linkPath);
+}
diff --git a/desktop/packages/win-shortcuts/src/index.mts b/desktop/packages/win-shortcuts/src/index.mts
new file mode 100644
index 0000000000..5e1ab260f6
--- /dev/null
+++ b/desktop/packages/win-shortcuts/src/index.mts
@@ -0,0 +1,3 @@
+// This module is the ESM entry point for the library.
+
+export * from './index.cjs';
diff --git a/desktop/packages/win-shortcuts/src/load.cts b/desktop/packages/win-shortcuts/src/load.cts
new file mode 100644
index 0000000000..e58b265901
--- /dev/null
+++ b/desktop/packages/win-shortcuts/src/load.cts
@@ -0,0 +1,11 @@
+// This module loads the platform-specific build of the addon on
+// the current system.
+
+/* eslint-disable @typescript-eslint/no-require-imports */
+module.exports = require('@neon-rs/load').proxy({
+ platforms: {
+ 'win32-x64-msvc': () => require('../dist/win32-x64-msvc'),
+ 'win32-arm64-msvc': () => require('../dist/win32-arm64-msvc'),
+ },
+ debug: () => require('../debug/index.node'),
+});
diff --git a/desktop/packages/win-shortcuts/tsconfig.json b/desktop/packages/win-shortcuts/tsconfig.json
new file mode 100644
index 0000000000..31e6603771
--- /dev/null
+++ b/desktop/packages/win-shortcuts/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "module": "node16",
+ "declaration": true,
+ "outDir": "lib"
+ },
+ "exclude": ["lib"]
+}
diff --git a/desktop/packages/win-shortcuts/win-shortcuts-rs/lib.rs b/desktop/packages/win-shortcuts/win-shortcuts-rs/lib.rs
new file mode 100644
index 0000000000..9cc0cea0c6
--- /dev/null
+++ b/desktop/packages/win-shortcuts/win-shortcuts-rs/lib.rs
@@ -0,0 +1,191 @@
+#![cfg(target_os = "windows")]
+
+use std::marker::PhantomData;
+use std::string::FromUtf16Error;
+use std::sync::{mpsc, OnceLock};
+
+use neon::prelude::*;
+use windows::core::{Interface, HSTRING, PCWSTR};
+use windows::Win32::System::Com::{
+ CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile, CLSCTX_INPROC_SERVER,
+ COINIT_APARTMENTTHREADED, STGM_READ,
+};
+use windows::Win32::UI::Shell::{IShellLinkW, ShellLink, SLGP_UNCPRIORITY};
+
+/// Messages that can be sent to the thread
+enum Message {
+ ResolveShortcut {
+ path: String,
+ result_tx: mpsc::Sender<Result<Option<String>, Error>>,
+ },
+}
+
+#[derive(thiserror::Error, Debug)]
+enum Error {
+ /// The handler thread is down
+ #[error("The handler thread is down")]
+ ThreadDown,
+
+ /// CoCreateInstance failed to create an IShellLinkW instance
+ #[error("CoCreateInstance failed to create an IShellLinkW instance")]
+ CreateInstance(#[source] windows::core::Error),
+
+ /// Failed to cast shortcut to IPersistFile
+ #[error("Failed to cast IShellLinkW")]
+ CastShortcut(#[source] windows::core::Error),
+
+ /// Failed to load shortcut
+ #[error("Failed to load shortcut .lnk")]
+ LoadShortcut(#[source] windows::core::Error),
+
+ /// Failed to retrieve IShellLinkW path
+ #[error("Failed to retrieve IShellLinkW link")]
+ GetPath(#[source] windows::core::Error),
+
+ /// Path is not valid UTF-16
+ #[error("Path is not valid UTF-16")]
+ Utf16ToString(#[source] FromUtf16Error),
+}
+
+/// Maximum path length of shortcut
+/// 32 KiB: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry
+const MAX_PATH_LEN: usize = 0x7fff;
+
+#[neon::main]
+fn main(mut cx: ModuleContext<'_>) -> NeonResult<()> {
+ cx.export_function("readShortcut", read_shortcut)?;
+
+ Ok(())
+}
+
+fn read_shortcut(mut cx: FunctionContext<'_>) -> JsResult<'_, JsValue> {
+ let link_path = cx.argument::<JsString>(0)?.value(&mut cx);
+
+ match read_shortcut_inner(link_path) {
+ Ok(Some(path)) => Ok(cx.string(path).as_value(&mut cx)),
+ Ok(None) => Ok(cx.null().as_value(&mut cx)),
+ Err(err) => cx.throw_error(format!("Failed to read shortcut: {err}")),
+ }
+}
+
+fn read_shortcut_inner(link_path: String) -> Result<Option<String>, Error> {
+ let tx = get_com_thread();
+
+ let (result_tx, result_rx) = mpsc::channel();
+ tx.send(Message::ResolveShortcut {
+ path: link_path,
+ result_tx,
+ })
+ .map_err(|_err| Error::ThreadDown)?;
+
+ result_rx.recv().map_err(|_err| Error::ThreadDown)?
+}
+
+/// Retrieve shortcut .lnk to its target path
+fn get_shortcut_path(path: &str) -> Result<Option<String>, Error> {
+ let shell_link_result: windows::core::Result<IShellLinkW> =
+ // SAFETY: We're passing a valid GUID pointer.
+ unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) };
+ let shell_link = shell_link_result.map_err(Error::CreateInstance)?;
+
+ // Load the .lnk using IPersistFile
+ let path = HSTRING::from(path);
+ let persist_file_result: windows::core::Result<IPersistFile> = shell_link.cast();
+ let persist_file = persist_file_result.map_err(Error::CastShortcut)?;
+
+ // SAFETY: HSTRING::from will ensure that path is a valid utf16 null-terminated string.
+ unsafe { persist_file.Load(PCWSTR(path.as_ptr()), STGM_READ) }.map_err(Error::LoadShortcut)?;
+
+ let mut target_buffer = [0u16; MAX_PATH_LEN];
+
+ // SAFETY: This function is trivially safe to call.
+ unsafe {
+ shell_link.GetPath(
+ &mut target_buffer,
+ std::ptr::null_mut(),
+ SLGP_UNCPRIORITY.0 as u32,
+ )
+ }
+ .map_err(Error::GetPath)?;
+
+ let utf16_slice = split_at_null(&target_buffer);
+ let s = String::from_utf16(utf16_slice).map_err(Error::Utf16ToString)?;
+ Ok(Some(s))
+}
+
+fn split_at_null(slice: &[u16]) -> &[u16] {
+ slice.split(|&c| c == 0).next().unwrap_or(slice)
+}
+
+/// Struct for safely handling initialization and deinitialization of the Windows COM library.
+/// A successful call to [CoInitializeEx] _needs_ to be accompanied by a call to [CoUninitialize],
+/// which is taken care by the drop implementation on [ComContext].
+///
+/// [CoInitializeEx] sets up thread-local state. Thus this type is `!Send` to stop it being moved
+/// to another thread.
+struct ComContext {
+ // HACK: until negative impls are stable, this how we stop `Send` from being impld
+ _do_not_impl_send: PhantomData<*mut ()>,
+}
+
+impl ComContext {
+ /// Create a new [ComContext].
+ ///
+ /// This will call [CoInitializeEx] now, and [CoUninitialize] when dropped.
+ ///
+ /// May return an error if [CoInitializeEx] was previously called with different arguments on
+ /// the same thread.
+ fn new() -> Result<Self, windows::core::Error> {
+ // SAFETY: This is paired with CoUninitialize in impl Drop
+ unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }.ok()?;
+
+ Ok(Self {
+ _do_not_impl_send: PhantomData,
+ })
+ }
+}
+
+impl Drop for ComContext {
+ fn drop(&mut self) {
+ // SAFETY: CoInitializeEx was called when this struct was created,
+ // and it was called on the same thread since ComContext is !Send.
+ unsafe {
+ CoUninitialize();
+ }
+ }
+}
+
+/// Retrieve a channel for communicating with the thread responsible for handling
+/// COM library safely.
+/// We spawn a thread in case the caller may have already initialized COM in an
+/// incompatible way.
+fn get_com_thread() -> mpsc::Sender<Message> {
+ static THREAD_SENDER: OnceLock<mpsc::Sender<Message>> = OnceLock::new();
+ THREAD_SENDER
+ .get_or_init(move || {
+ let (tx, rx) = mpsc::channel();
+
+ std::thread::spawn(move || {
+ let com = match ComContext::new() {
+ Ok(com) => com,
+ Err(e) => {
+ eprintln!("Failed to initialize ComContext: {e}");
+ return;
+ }
+ };
+
+ while let Ok(msg) = rx.recv() {
+ match msg {
+ Message::ResolveShortcut { path, result_tx } => {
+ let _ = result_tx.send(get_shortcut_path(&path));
+ }
+ }
+ }
+
+ drop(com);
+ });
+
+ tx
+ })
+ .clone()
+}