Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
+440
@@ -0,0 +1,440 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_win_toast"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"serde",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "fluxer_win_toast"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8", "serde-json"]}
|
||||
napi-derive = "3.5.6"
|
||||
serde = {version = "1.0", features = ["derive"]}
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Data_Xml_Dom",
|
||||
"Foundation",
|
||||
"UI_Notifications",
|
||||
"Win32_System_WinRT",
|
||||
]}
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
args: string;
|
||||
activationType?: 'foreground' | 'background' | 'protocol';
|
||||
imageUri?: string;
|
||||
hintInputId?: string;
|
||||
}
|
||||
|
||||
export interface ToastBindingText {
|
||||
text: string;
|
||||
hintMaxLines?: number;
|
||||
hint?: 'attribution';
|
||||
}
|
||||
|
||||
export interface ToastInputBox {
|
||||
id: string;
|
||||
type: 'text' | 'selection';
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
options?: ReadonlyArray<{id: string; content: string}>;
|
||||
}
|
||||
|
||||
export interface ToastImage {
|
||||
uri: string;
|
||||
placement?: 'hero' | 'appLogoOverride';
|
||||
hintCrop?: 'circle' | 'none';
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface ToastNotifyOptions {
|
||||
aumid: string;
|
||||
tag?: string;
|
||||
group?: string;
|
||||
expirationTime?: string;
|
||||
scenario?: 'default' | 'urgent' | 'reminder' | 'incomingCall' | 'alarm';
|
||||
audio?: 'default' | 'silent' | {silent?: boolean; loop?: boolean; src?: string};
|
||||
lines: ReadonlyArray<ToastBindingText>;
|
||||
images?: ReadonlyArray<ToastImage>;
|
||||
inputs?: ReadonlyArray<ToastInputBox>;
|
||||
actions?: ReadonlyArray<ToastAction>;
|
||||
}
|
||||
|
||||
export interface ToastSupport {
|
||||
supported: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export declare function isSupported(): ToastSupport;
|
||||
|
||||
export declare function notify(opts: ToastNotifyOptions): Promise<void>;
|
||||
|
||||
export declare function dismiss(opts: {aumid: string; tag: string; group?: string}): Promise<void>;
|
||||
|
||||
export declare function clear(opts: {aumid: string}): Promise<void>;
|
||||
|
||||
export declare const loadError: Error | null;
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/win-toast';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error(`@fluxer/win-toast is only supported on Windows, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'win-toast.win32-x64-msvc.node';
|
||||
case 'arm64':
|
||||
return 'win-toast.win32-arm64-msvc.node';
|
||||
default:
|
||||
throw new Error(`Unsupported Windows architecture: ${process.arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, nativeFileName());
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
} catch (error) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
const stubSupport = () => ({supported: false, reason: loadError ? String(loadError.message) : 'addon not loaded'});
|
||||
const stubAsync = () => Promise.reject(loadError ?? new Error('@fluxer/win-toast not loaded'));
|
||||
|
||||
module.exports = {
|
||||
isSupported: binding ? binding.isSupported : stubSupport,
|
||||
notify: binding ? binding.notify : stubAsync,
|
||||
dismiss: binding ? binding.dismiss : stubAsync,
|
||||
clear: binding ? binding.clear : stubAsync,
|
||||
loadError,
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {basename} = require('node:path');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
|
||||
const MAX_TEXT_LENGTH = 6000;
|
||||
const MAX_DIRECTORY_ENTRIES = 80;
|
||||
|
||||
function trimText(value, limit = MAX_TEXT_LENGTH) {
|
||||
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
|
||||
}
|
||||
|
||||
function errorDiagnostic(error) {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || 'Error',
|
||||
message: error.message,
|
||||
code: error.code || null,
|
||||
stack: trimText(error.stack || error.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: typeof error,
|
||||
message: trimText(String(error)),
|
||||
code: null,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const lines = [];
|
||||
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
|
||||
if (diagnostic.stack) lines.push(diagnostic.stack);
|
||||
else if (diagnostic.message) lines.push(diagnostic.message);
|
||||
return trimText(lines.join('\n'));
|
||||
}
|
||||
|
||||
function fileDiagnostic(filePath) {
|
||||
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
size: stat.size,
|
||||
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {path: filePath, exists: false, error: reason};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return 'not resolved';
|
||||
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
|
||||
return [
|
||||
`exists=true`,
|
||||
`size=${diagnostic.size}`,
|
||||
`mode=${diagnostic.mode}`,
|
||||
`mtime=${diagnostic.mtime}`,
|
||||
`isFile=${diagnostic.isFile}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function directoryDiagnostic(dirPath) {
|
||||
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
|
||||
try {
|
||||
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
||||
}));
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: true,
|
||||
entries: visible,
|
||||
total: entries.length,
|
||||
omitted: Math.max(0, entries.length - visible.length),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
entries: [],
|
||||
total: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return '<unavailable>';
|
||||
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
|
||||
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
|
||||
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
|
||||
return [...entries, ...suffix].join('\n') || '<empty>';
|
||||
}
|
||||
|
||||
function selectedEnvironmentNames(skipNativeProbeEnv) {
|
||||
const names = [
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DISPLAY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_TYPE',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'PULSE_SERVER',
|
||||
'PIPEWIRE_REMOTE',
|
||||
'PATH',
|
||||
];
|
||||
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
|
||||
return names;
|
||||
}
|
||||
|
||||
function environmentDiagnostics(skipNativeProbeEnv) {
|
||||
return Object.fromEntries(
|
||||
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnvironment(diagnostic) {
|
||||
return Object.entries(diagnostic)
|
||||
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function runtimeDiagnostics() {
|
||||
const versions = process.versions || {};
|
||||
let reportHeader = null;
|
||||
if (process.report && typeof process.report.getReport === 'function') {
|
||||
try {
|
||||
reportHeader = process.report.getReport().header || null;
|
||||
} catch {
|
||||
reportHeader = null;
|
||||
}
|
||||
}
|
||||
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
|
||||
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
|
||||
return {
|
||||
node: versions.node || null,
|
||||
electron: versions.electron || null,
|
||||
modules: versions.modules || null,
|
||||
napi: versions.napi || null,
|
||||
v8: versions.v8 || null,
|
||||
uv: versions.uv || null,
|
||||
openssl: versions.openssl || null,
|
||||
glibcRuntime,
|
||||
glibcCompiler,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: typeof os.version === 'function' ? os.version() : null,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath || null,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeDiagnostics(diagnostic) {
|
||||
return [
|
||||
`node=${diagnostic.node || '<unknown>'}`,
|
||||
`electron=${diagnostic.electron || '<none>'}`,
|
||||
`modules=${diagnostic.modules || '<unknown>'}`,
|
||||
`napi=${diagnostic.napi || '<unknown>'}`,
|
||||
`v8=${diagnostic.v8 || '<unknown>'}`,
|
||||
`uv=${diagnostic.uv || '<unknown>'}`,
|
||||
`openssl=${diagnostic.openssl || '<unknown>'}`,
|
||||
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
|
||||
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
|
||||
`process=${diagnostic.platform}/${diagnostic.arch}`,
|
||||
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
|
||||
`execPath=${diagnostic.execPath}`,
|
||||
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
|
||||
`cwd=${diagnostic.cwd}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
|
||||
/^vcruntime\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcp\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcr\d+(?:_\d+)?\.dll$/i,
|
||||
/^concrt\d+\.dll$/i,
|
||||
/^vcamp\d+\.dll$/i,
|
||||
/^vcomp\d+\.dll$/i,
|
||||
];
|
||||
|
||||
function readPeImports(filePath) {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length < 0x40) return null;
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
|
||||
const coffOffset = peOffset + 4;
|
||||
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
|
||||
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
|
||||
const magic = buffer.readUInt16LE(optionalHeaderOffset);
|
||||
if (magic !== 0x10b && magic !== 0x20b) return null;
|
||||
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
|
||||
const importEntryOffset = dataDirectoriesOffset + 8;
|
||||
if (importEntryOffset + 8 > buffer.length) return null;
|
||||
const importRva = buffer.readUInt32LE(importEntryOffset);
|
||||
if (importRva === 0) return [];
|
||||
const sections = [];
|
||||
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
|
||||
for (let i = 0; i < numberOfSections; i++) {
|
||||
const base = sectionTableOffset + i * 40;
|
||||
if (base + 40 > buffer.length) return null;
|
||||
sections.push({
|
||||
virtualSize: buffer.readUInt32LE(base + 8),
|
||||
virtualAddress: buffer.readUInt32LE(base + 12),
|
||||
rawSize: buffer.readUInt32LE(base + 16),
|
||||
rawPointer: buffer.readUInt32LE(base + 20),
|
||||
});
|
||||
}
|
||||
const rvaToOffset = (rva) => {
|
||||
for (const s of sections) {
|
||||
const span = Math.max(s.virtualSize, s.rawSize);
|
||||
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
|
||||
return rva - s.virtualAddress + s.rawPointer;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const readCString = (offset) => {
|
||||
let end = offset;
|
||||
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||
return buffer.toString('ascii', offset, end);
|
||||
};
|
||||
const importTableOffset = rvaToOffset(importRva);
|
||||
if (importTableOffset < 0) return [];
|
||||
const imports = new Set();
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const base = importTableOffset + i * 20;
|
||||
if (base + 20 > buffer.length) break;
|
||||
const lookupRva = buffer.readUInt32LE(base);
|
||||
const nameRva = buffer.readUInt32LE(base + 12);
|
||||
const iatRva = buffer.readUInt32LE(base + 16);
|
||||
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
|
||||
const nameOffset = rvaToOffset(nameRva);
|
||||
if (nameOffset < 0) continue;
|
||||
const name = readCString(nameOffset);
|
||||
if (name) imports.add(name);
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function windowsImportProbe(nativePath) {
|
||||
const imports = readPeImports(nativePath);
|
||||
if (imports === null) return null;
|
||||
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const redistributable = sortedImports.filter((dll) =>
|
||||
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
|
||||
);
|
||||
return {
|
||||
command: ['pe-imports', nativePath],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
stdout: sortedImports.join('\n') || null,
|
||||
stderr: null,
|
||||
missing: [],
|
||||
redistributable,
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyProbe(nativePath) {
|
||||
if (!nativePath || !existsSync(nativePath)) return null;
|
||||
if (process.platform === 'win32') return windowsImportProbe(nativePath);
|
||||
const command =
|
||||
process.platform === 'linux'
|
||||
? ['ldd', nativePath]
|
||||
: process.platform === 'darwin'
|
||||
? ['otool', '-L', nativePath]
|
||||
: null;
|
||||
if (!command) return null;
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = trimText(result.stdout);
|
||||
const stderr = trimText(result.stderr);
|
||||
const missing =
|
||||
process.platform === 'linux' && stdout
|
||||
? stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.includes('not found'))
|
||||
: [];
|
||||
return {
|
||||
command,
|
||||
status: result.status,
|
||||
signal: result.signal || null,
|
||||
error: result.error ? result.error.message : null,
|
||||
stdout,
|
||||
stderr,
|
||||
missing,
|
||||
redistributable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDependencyProbe(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const status = diagnostic.error
|
||||
? `error=${diagnostic.error}`
|
||||
: diagnostic.signal
|
||||
? `signal=${diagnostic.signal}`
|
||||
: `status=${diagnostic.status}`;
|
||||
return [
|
||||
`$ ${diagnostic.command.join(' ')}`,
|
||||
status,
|
||||
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
|
||||
diagnostic.redistributable?.length
|
||||
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
|
||||
: null,
|
||||
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
|
||||
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatExtraDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
if (typeof diagnostic === 'string') return diagnostic;
|
||||
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
|
||||
return `${diagnostic.name}:\n${diagnostic.text}`;
|
||||
}
|
||||
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
|
||||
}
|
||||
|
||||
function collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
moduleName,
|
||||
reason,
|
||||
target: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packageDir: packageDir || null,
|
||||
nativeRoot: nativeRoot || null,
|
||||
nativePath: nativePath || null,
|
||||
nativeFile: nativePath ? basename(nativePath) : null,
|
||||
nativeFileStat: fileDiagnostic(nativePath),
|
||||
runtime: runtimeDiagnostics(),
|
||||
environment: environmentDiagnostics(skipNativeProbeEnv),
|
||||
nativeRootEntries: directoryDiagnostic(nativeRoot),
|
||||
dependencyProbe: dependencyProbe(nativePath),
|
||||
extraDiagnostics: extraDiagnostics.filter(Boolean),
|
||||
cause: errorDiagnostic(cause),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNativeDiagnostics(diagnostics) {
|
||||
const sections = [
|
||||
`module=${diagnostics.moduleName}`,
|
||||
`reason=${diagnostics.reason}`,
|
||||
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
|
||||
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
|
||||
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
|
||||
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
|
||||
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
|
||||
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
|
||||
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
|
||||
`environment:\n${formatEnvironment(diagnostics.environment)}`,
|
||||
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
|
||||
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
|
||||
];
|
||||
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
|
||||
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
|
||||
const causeText = formatErrorDiagnostic(diagnostics.cause);
|
||||
if (causeText) sections.push(`cause:\n${causeText}`);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function isNativeLoadError(error) {
|
||||
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
|
||||
}
|
||||
|
||||
function createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
if (isNativeLoadError(cause)) return cause;
|
||||
const diagnostics = collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics,
|
||||
});
|
||||
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
|
||||
error.name = 'NativeModuleLoadError';
|
||||
error[NATIVE_LOAD_ERROR_MARKER] = true;
|
||||
error.nativeDiagnostics = diagnostics;
|
||||
error.toJSON = () => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
nativeDiagnostics: diagnostics,
|
||||
});
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
|
||||
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
|
||||
return null;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
|
||||
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.status === 0) return null;
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `safety probe terminated by signal ${result.signal}`
|
||||
: `safety probe exited with code ${result.status}`;
|
||||
return createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics: [
|
||||
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
|
||||
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
|
||||
if (!existsSync(nativePath)) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'native binary not found',
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const nativeProbeError = probe
|
||||
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
|
||||
: null;
|
||||
if (nativeProbeError) {
|
||||
return {binding: null, loadError: nativeProbeError};
|
||||
}
|
||||
try {
|
||||
return {binding: require(nativePath), loadError: null};
|
||||
} catch (error) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'require(nativePath) threw',
|
||||
cause: error,
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectNativeDiagnostics,
|
||||
createNativeLoadError,
|
||||
formatNativeDiagnostics,
|
||||
isNativeLoadError,
|
||||
loadNativeBinding,
|
||||
probeNativeBinary,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@fluxer/win-toast",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"win-toast.win32-x64-msvc.node",
|
||||
"win-toast.win32-arm64-msvc.node"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "cargo test --manifest-path Cargo.toml",
|
||||
"check:windows:x64": "cargo check --manifest-path Cargo.toml --target x86_64-pc-windows-msvc --tests",
|
||||
"check:windows:arm64": "cargo check --manifest-path Cargo.toml --target aarch64-pc-windows-msvc --tests"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use napi::Task;
|
||||
use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status, Unknown};
|
||||
use napi_derive::napi;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawToastNotifyOptions {
|
||||
aumid: Option<String>,
|
||||
tag: Option<String>,
|
||||
group: Option<String>,
|
||||
expiration_time: Option<String>,
|
||||
scenario: Option<String>,
|
||||
audio: Option<RawAudio>,
|
||||
lines: Option<Vec<RawToastText>>,
|
||||
images: Option<Vec<RawToastImage>>,
|
||||
inputs: Option<Vec<RawToastInput>>,
|
||||
actions: Option<Vec<RawToastAction>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawToastText {
|
||||
text: Option<String>,
|
||||
hint_max_lines: Option<i32>,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawToastImage {
|
||||
uri: Option<String>,
|
||||
placement: Option<String>,
|
||||
hint_crop: Option<String>,
|
||||
alt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawToastInput {
|
||||
id: Option<String>,
|
||||
r#type: Option<String>,
|
||||
placeholder: Option<String>,
|
||||
title: Option<String>,
|
||||
options: Option<Vec<RawToastInputOption>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawToastInputOption {
|
||||
id: Option<String>,
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawToastAction {
|
||||
label: Option<String>,
|
||||
args: Option<String>,
|
||||
activation_type: Option<String>,
|
||||
image_uri: Option<String>,
|
||||
hint_input_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawAudio {
|
||||
Keyword(String),
|
||||
Object(RawAudioObject),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawAudioObject {
|
||||
silent: Option<bool>,
|
||||
#[serde(rename = "loop")]
|
||||
loop_audio: Option<bool>,
|
||||
src: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastSpec {
|
||||
aumid: String,
|
||||
tag: Option<String>,
|
||||
group: Option<String>,
|
||||
expiration_time: Option<WindowsDateTimeTicks>,
|
||||
scenario: Option<String>,
|
||||
audio_src: Option<String>,
|
||||
audio_silent: bool,
|
||||
audio_loop: bool,
|
||||
lines: Vec<ToastTextSpec>,
|
||||
images: Vec<ToastImageSpec>,
|
||||
inputs: Vec<ToastInputSpec>,
|
||||
actions: Vec<ToastActionSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct WindowsDateTimeTicks(i64);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastTextSpec {
|
||||
text: String,
|
||||
hint_max_lines: Option<i32>,
|
||||
is_attribution: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastImageSpec {
|
||||
uri: String,
|
||||
placement: Option<String>,
|
||||
hint_crop: Option<String>,
|
||||
alt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastInputSpec {
|
||||
id: String,
|
||||
r#type: String,
|
||||
placeholder: Option<String>,
|
||||
title: Option<String>,
|
||||
options: Vec<ToastInputOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastInputOption {
|
||||
id: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ToastActionSpec {
|
||||
label: String,
|
||||
args: String,
|
||||
activation_type: Option<String>,
|
||||
image_uri: Option<String>,
|
||||
hint_input_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawDismissOptions {
|
||||
aumid: Option<String>,
|
||||
tag: Option<String>,
|
||||
group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawClearOptions {
|
||||
aumid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum HistoryOp {
|
||||
Clear,
|
||||
RemoveTag { tag: String, group: Option<String> },
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ToastSupport {
|
||||
pub supported: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub struct NotifyTask {
|
||||
spec: ToastSpec,
|
||||
}
|
||||
|
||||
pub struct HistoryTask {
|
||||
aumid: String,
|
||||
op: HistoryOp,
|
||||
}
|
||||
|
||||
#[napi(js_name = "isSupported")]
|
||||
pub fn is_supported() -> ToastSupport {
|
||||
if cfg!(target_os = "windows") {
|
||||
ToastSupport {
|
||||
supported: true,
|
||||
reason: None,
|
||||
}
|
||||
} else {
|
||||
ToastSupport {
|
||||
supported: false,
|
||||
reason: Some("win-toast only runs on Windows".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn notify(env: Env, opts: Unknown) -> Result<AsyncTask<NotifyTask>> {
|
||||
let raw = env
|
||||
.from_js_value::<RawToastNotifyOptions, _>(opts)
|
||||
.map_err(|_| invalid_arg("invalid toast spec"))?;
|
||||
Ok(AsyncTask::new(NotifyTask {
|
||||
spec: validate_toast_spec(raw)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn dismiss(env: Env, opts: Unknown) -> Result<AsyncTask<HistoryTask>> {
|
||||
let raw = env
|
||||
.from_js_value::<RawDismissOptions, _>(opts)
|
||||
.map_err(|_| invalid_arg("history op requires an options object"))?;
|
||||
let aumid = require_non_empty(raw.aumid, "aumid is required")?;
|
||||
let tag = require_non_empty(raw.tag, "tag is required for dismiss")?;
|
||||
Ok(AsyncTask::new(HistoryTask {
|
||||
aumid,
|
||||
op: HistoryOp::RemoveTag {
|
||||
tag,
|
||||
group: raw.group,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn clear(env: Env, opts: Unknown) -> Result<AsyncTask<HistoryTask>> {
|
||||
let raw = env
|
||||
.from_js_value::<RawClearOptions, _>(opts)
|
||||
.map_err(|_| invalid_arg("history op requires an options object"))?;
|
||||
Ok(AsyncTask::new(HistoryTask {
|
||||
aumid: require_non_empty(raw.aumid, "aumid is required")?,
|
||||
op: HistoryOp::Clear,
|
||||
}))
|
||||
}
|
||||
|
||||
impl Task for NotifyTask {
|
||||
type Output = ();
|
||||
type JsValue = ();
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
platform::show_toast(&self.spec)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Task for HistoryTask {
|
||||
type Output = ();
|
||||
type JsValue = ();
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
platform::history_op(&self.aumid, &self.op)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_toast_spec(raw: RawToastNotifyOptions) -> Result<ToastSpec> {
|
||||
let (audio_silent, audio_loop, audio_src) = parse_audio(raw.audio);
|
||||
Ok(ToastSpec {
|
||||
aumid: require_non_empty(raw.aumid, "aumid is required")?,
|
||||
tag: raw.tag,
|
||||
group: raw.group,
|
||||
expiration_time: raw
|
||||
.expiration_time
|
||||
.as_deref()
|
||||
.map(parse_windows_datetime)
|
||||
.transpose()?,
|
||||
scenario: raw.scenario,
|
||||
audio_src,
|
||||
audio_silent,
|
||||
audio_loop,
|
||||
lines: parse_lines(raw.lines)?,
|
||||
images: parse_images(raw.images)?,
|
||||
inputs: parse_inputs(raw.inputs)?,
|
||||
actions: parse_actions(raw.actions)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_audio(raw: Option<RawAudio>) -> (bool, bool, Option<String>) {
|
||||
match raw {
|
||||
Some(RawAudio::Keyword(keyword)) if keyword == "silent" => (true, false, None),
|
||||
Some(RawAudio::Object(raw)) => (
|
||||
raw.silent.unwrap_or(false),
|
||||
raw.loop_audio.unwrap_or(false),
|
||||
raw.src,
|
||||
),
|
||||
_ => (false, false, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_lines(raw: Option<Vec<RawToastText>>) -> Result<Vec<ToastTextSpec>> {
|
||||
let raw = raw.ok_or_else(|| invalid_arg("invalid toast spec"))?;
|
||||
if raw.is_empty() {
|
||||
return Err(invalid_arg("invalid toast spec"));
|
||||
}
|
||||
raw.into_iter()
|
||||
.map(|line| {
|
||||
Ok(ToastTextSpec {
|
||||
text: require_non_empty(line.text, "invalid toast spec")?,
|
||||
hint_max_lines: line.hint_max_lines,
|
||||
is_attribution: line.hint.as_deref() == Some("attribution"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_images(raw: Option<Vec<RawToastImage>>) -> Result<Vec<ToastImageSpec>> {
|
||||
raw.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|image| {
|
||||
Ok(ToastImageSpec {
|
||||
uri: require_non_empty(image.uri, "invalid toast spec")?,
|
||||
placement: image.placement,
|
||||
hint_crop: image.hint_crop,
|
||||
alt: image.alt,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_inputs(raw: Option<Vec<RawToastInput>>) -> Result<Vec<ToastInputSpec>> {
|
||||
raw.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|input| {
|
||||
Ok(ToastInputSpec {
|
||||
id: require_non_empty(input.id, "invalid toast spec")?,
|
||||
r#type: require_non_empty(input.r#type, "invalid toast spec")?,
|
||||
placeholder: input.placeholder,
|
||||
title: input.title,
|
||||
options: input
|
||||
.options
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|option| {
|
||||
Ok(ToastInputOption {
|
||||
id: require_non_empty(option.id, "invalid toast spec")?,
|
||||
content: require_non_empty(option.content, "invalid toast spec")?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_actions(raw: Option<Vec<RawToastAction>>) -> Result<Vec<ToastActionSpec>> {
|
||||
raw.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|action| {
|
||||
Ok(ToastActionSpec {
|
||||
label: require_non_empty(action.label, "invalid toast spec")?,
|
||||
args: require_non_empty(action.args, "invalid toast spec")?,
|
||||
activation_type: action.activation_type,
|
||||
image_uri: action.image_uri,
|
||||
hint_input_id: action.hint_input_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn build_toast_xml(spec: &ToastSpec) -> String {
|
||||
let mut xml = String::new();
|
||||
xml.push_str("<toast");
|
||||
if let Some(scenario) = spec.scenario.as_deref() {
|
||||
xml.push_str(" scenario=\"");
|
||||
push_escaped(&mut xml, scenario);
|
||||
xml.push('"');
|
||||
}
|
||||
xml.push('>');
|
||||
xml.push_str("<visual><binding template=\"ToastGeneric\">");
|
||||
|
||||
for image in &spec.images {
|
||||
xml.push_str("<image src=\"");
|
||||
push_escaped(&mut xml, &image.uri);
|
||||
xml.push('"');
|
||||
if let Some(placement) = image.placement.as_deref() {
|
||||
xml.push_str(" placement=\"");
|
||||
push_escaped(&mut xml, placement);
|
||||
xml.push('"');
|
||||
}
|
||||
if let Some(hint_crop) = image.hint_crop.as_deref() {
|
||||
xml.push_str(" hint-crop=\"");
|
||||
push_escaped(&mut xml, hint_crop);
|
||||
xml.push('"');
|
||||
}
|
||||
if let Some(alt) = image.alt.as_deref() {
|
||||
xml.push_str(" alt=\"");
|
||||
push_escaped(&mut xml, alt);
|
||||
xml.push('"');
|
||||
}
|
||||
xml.push_str("/>");
|
||||
}
|
||||
|
||||
for line in &spec.lines {
|
||||
xml.push_str("<text");
|
||||
if line.is_attribution {
|
||||
xml.push_str(" placement=\"attribution\"");
|
||||
}
|
||||
if let Some(max_lines) = line.hint_max_lines {
|
||||
xml.push_str(" hint-maxLines=\"");
|
||||
xml.push_str(&max_lines.to_string());
|
||||
xml.push('"');
|
||||
}
|
||||
xml.push('>');
|
||||
push_escaped(&mut xml, &line.text);
|
||||
xml.push_str("</text>");
|
||||
}
|
||||
|
||||
xml.push_str("</binding></visual>");
|
||||
|
||||
if !spec.inputs.is_empty() || !spec.actions.is_empty() {
|
||||
xml.push_str("<actions>");
|
||||
for input in &spec.inputs {
|
||||
xml.push_str("<input id=\"");
|
||||
push_escaped(&mut xml, &input.id);
|
||||
xml.push_str("\" type=\"");
|
||||
push_escaped(&mut xml, &input.r#type);
|
||||
xml.push('"');
|
||||
if let Some(placeholder) = input.placeholder.as_deref() {
|
||||
xml.push_str(" placeHolderContent=\"");
|
||||
push_escaped(&mut xml, placeholder);
|
||||
xml.push('"');
|
||||
}
|
||||
if let Some(title) = input.title.as_deref() {
|
||||
xml.push_str(" title=\"");
|
||||
push_escaped(&mut xml, title);
|
||||
xml.push('"');
|
||||
}
|
||||
if input.options.is_empty() {
|
||||
xml.push_str("/>");
|
||||
} else {
|
||||
xml.push('>');
|
||||
for option in &input.options {
|
||||
xml.push_str("<selection id=\"");
|
||||
push_escaped(&mut xml, &option.id);
|
||||
xml.push_str("\" content=\"");
|
||||
push_escaped(&mut xml, &option.content);
|
||||
xml.push_str("\"/>");
|
||||
}
|
||||
xml.push_str("</input>");
|
||||
}
|
||||
}
|
||||
|
||||
for action in &spec.actions {
|
||||
xml.push_str("<action content=\"");
|
||||
push_escaped(&mut xml, &action.label);
|
||||
xml.push_str("\" arguments=\"");
|
||||
push_escaped(&mut xml, &action.args);
|
||||
xml.push_str("\" activationType=\"");
|
||||
push_escaped(
|
||||
&mut xml,
|
||||
action.activation_type.as_deref().unwrap_or("foreground"),
|
||||
);
|
||||
xml.push('"');
|
||||
if let Some(image_uri) = action.image_uri.as_deref() {
|
||||
xml.push_str(" imageUri=\"");
|
||||
push_escaped(&mut xml, image_uri);
|
||||
xml.push('"');
|
||||
}
|
||||
if let Some(hint_input_id) = action.hint_input_id.as_deref() {
|
||||
xml.push_str(" hint-inputId=\"");
|
||||
push_escaped(&mut xml, hint_input_id);
|
||||
xml.push('"');
|
||||
}
|
||||
xml.push_str("/>");
|
||||
}
|
||||
xml.push_str("</actions>");
|
||||
}
|
||||
|
||||
if spec.audio_silent {
|
||||
xml.push_str("<audio silent=\"true\"/>");
|
||||
} else if let Some(src) = spec.audio_src.as_deref() {
|
||||
xml.push_str("<audio src=\"");
|
||||
push_escaped(&mut xml, src);
|
||||
xml.push('"');
|
||||
if spec.audio_loop {
|
||||
xml.push_str(" loop=\"true\"");
|
||||
}
|
||||
xml.push_str("/>");
|
||||
}
|
||||
|
||||
xml.push_str("</toast>");
|
||||
xml
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn push_escaped(out: &mut String, value: &str) {
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_windows_datetime(value: &str) -> Result<WindowsDateTimeTicks> {
|
||||
parse_rfc3339_utc_100ns(value)
|
||||
.map(WindowsDateTimeTicks)
|
||||
.ok_or_else(|| invalid_arg("expirationTime must be an ISO timestamp"))
|
||||
}
|
||||
|
||||
fn parse_rfc3339_utc_100ns(value: &str) -> Option<i64> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let year = parse_digits(bytes, 0, 4)? as i32;
|
||||
expect_byte(bytes, 4, b'-')?;
|
||||
let month = parse_digits(bytes, 5, 2)? as u32;
|
||||
expect_byte(bytes, 7, b'-')?;
|
||||
let day = parse_digits(bytes, 8, 2)? as u32;
|
||||
if bytes.get(10).copied()? != b'T' && bytes.get(10).copied()? != b't' {
|
||||
return None;
|
||||
}
|
||||
let hour = parse_digits(bytes, 11, 2)? as u32;
|
||||
expect_byte(bytes, 13, b':')?;
|
||||
let minute = parse_digits(bytes, 14, 2)? as u32;
|
||||
expect_byte(bytes, 16, b':')?;
|
||||
let second = parse_digits(bytes, 17, 2)? as u32;
|
||||
|
||||
if !valid_date(year, month, day) || hour > 23 || minute > 59 || second > 59 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut index = 19;
|
||||
let mut fractional_100ns = 0_i64;
|
||||
if bytes.get(index).copied() == Some(b'.') {
|
||||
index += 1;
|
||||
let start = index;
|
||||
let mut digits = 0;
|
||||
while let Some(byte) = bytes.get(index).copied() {
|
||||
if !byte.is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
if digits < 7 {
|
||||
fractional_100ns = (fractional_100ns * 10) + i64::from(byte - b'0');
|
||||
}
|
||||
digits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if index == start {
|
||||
return None;
|
||||
}
|
||||
for _ in digits..7 {
|
||||
fractional_100ns *= 10;
|
||||
}
|
||||
}
|
||||
|
||||
let offset_seconds = match bytes.get(index).copied()? {
|
||||
b'Z' | b'z' => {
|
||||
index += 1;
|
||||
0_i64
|
||||
}
|
||||
b'+' | b'-' => {
|
||||
let sign = if bytes[index] == b'+' { 1_i64 } else { -1_i64 };
|
||||
index += 1;
|
||||
let offset_hour = parse_digits(bytes, index, 2)? as i64;
|
||||
index += 2;
|
||||
expect_byte(bytes, index, b':')?;
|
||||
index += 1;
|
||||
let offset_minute = parse_digits(bytes, index, 2)? as i64;
|
||||
index += 2;
|
||||
if offset_hour > 23 || offset_minute > 59 {
|
||||
return None;
|
||||
}
|
||||
sign * ((offset_hour * 3600) + (offset_minute * 60))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if index != bytes.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let days = days_from_civil(year, month, day);
|
||||
let unix_seconds = (i128::from(days) * 86_400) + i128::from(hour * 3600 + minute * 60 + second)
|
||||
- i128::from(offset_seconds);
|
||||
const WINDOWS_TICK_OFFSET_SECONDS: i128 = 11_644_473_600;
|
||||
let windows_ticks =
|
||||
(unix_seconds + WINDOWS_TICK_OFFSET_SECONDS) * 10_000_000 + i128::from(fractional_100ns);
|
||||
i64::try_from(windows_ticks)
|
||||
.ok()
|
||||
.filter(|ticks| *ticks >= 0)
|
||||
}
|
||||
|
||||
fn parse_digits(bytes: &[u8], start: usize, len: usize) -> Option<u32> {
|
||||
let mut value = 0_u32;
|
||||
for index in start..start + len {
|
||||
let byte = *bytes.get(index)?;
|
||||
if !byte.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
value = (value * 10) + u32::from(byte - b'0');
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn expect_byte(bytes: &[u8], index: usize, expected: u8) -> Option<()> {
|
||||
(*bytes.get(index)? == expected).then_some(())
|
||||
}
|
||||
|
||||
fn valid_date(year: i32, month: u32, day: u32) -> bool {
|
||||
if !(1..=12).contains(&month) {
|
||||
return false;
|
||||
}
|
||||
let max_day = match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap_year(year) => 29,
|
||||
2 => 28,
|
||||
_ => return false,
|
||||
};
|
||||
(1..=max_day).contains(&day)
|
||||
}
|
||||
|
||||
fn is_leap_year(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
|
||||
let year = year - i32::from(month <= 2);
|
||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||
let yoe = year - era * 400;
|
||||
let month = month as i32;
|
||||
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day as i32 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
i64::from(era * 146_097 + doe - 719_468)
|
||||
}
|
||||
|
||||
fn require_non_empty(value: Option<String>, message: &'static str) -> Result<String> {
|
||||
match value {
|
||||
Some(value) if !value.is_empty() => Ok(value),
|
||||
_ => Err(invalid_arg(message)),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_arg(message: &'static str) -> Error {
|
||||
Error::new(Status::InvalidArg, message)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use super::{HistoryOp, ToastSpec, build_toast_xml};
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
use windows::Data::Xml::Dom::XmlDocument;
|
||||
use windows::Foundation::{DateTime, IReference, PropertyValue};
|
||||
use windows::UI::Notifications::{ToastNotification, ToastNotificationManager};
|
||||
use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize};
|
||||
use windows::core::{HSTRING, Interface};
|
||||
|
||||
pub(super) fn show_toast(spec: &ToastSpec) -> Result<()> {
|
||||
let _winrt = WinRtApartment::initialize()?;
|
||||
|
||||
let xml = build_toast_xml(spec);
|
||||
let document = XmlDocument::new()
|
||||
.map_err(|err| hresult_error("XmlDocument activation failed", err))?;
|
||||
document
|
||||
.LoadXml(&HSTRING::from(xml.as_str()))
|
||||
.map_err(|err| hresult_error("Toast XML load failed", err))?;
|
||||
|
||||
let notification = ToastNotification::CreateToastNotification(&document)
|
||||
.map_err(|err| hresult_error("ToastNotification creation failed", err))?;
|
||||
if let Some(tag) = spec.tag.as_deref() {
|
||||
notification
|
||||
.SetTag(&HSTRING::from(tag))
|
||||
.map_err(|err| hresult_error("ToastNotification SetTag failed", err))?;
|
||||
}
|
||||
if let Some(group) = spec.group.as_deref() {
|
||||
notification
|
||||
.SetGroup(&HSTRING::from(group))
|
||||
.map_err(|err| hresult_error("ToastNotification SetGroup failed", err))?;
|
||||
}
|
||||
if let Some(expiration_time) = spec.expiration_time {
|
||||
let value = PropertyValue::CreateDateTime(DateTime {
|
||||
UniversalTime: expiration_time.0,
|
||||
})
|
||||
.map_err(|err| hresult_error("PropertyValue::CreateDateTime failed", err))?;
|
||||
let reference: IReference<DateTime> = value
|
||||
.cast()
|
||||
.map_err(|err| hresult_error("QueryInterface(IReference<DateTime>) failed", err))?;
|
||||
notification
|
||||
.SetExpirationTime(&reference)
|
||||
.map_err(|err| hresult_error("ToastNotification SetExpirationTime failed", err))?;
|
||||
}
|
||||
|
||||
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(
|
||||
spec.aumid.as_str(),
|
||||
))
|
||||
.map_err(|err| hresult_error("ToastNotifier creation failed", err))?;
|
||||
|
||||
notifier
|
||||
.Show(¬ification)
|
||||
.map_err(|err| hresult_error("ToastNotifier Show failed", err))
|
||||
}
|
||||
|
||||
pub(super) fn history_op(aumid: &str, op: &HistoryOp) -> Result<()> {
|
||||
let _winrt = WinRtApartment::initialize()?;
|
||||
let history = ToastNotificationManager::History()
|
||||
.map_err(|err| hresult_error("ToastNotificationHistory activation failed", err))?;
|
||||
let aumid = HSTRING::from(aumid);
|
||||
match op {
|
||||
HistoryOp::Clear => history
|
||||
.ClearWithId(&aumid)
|
||||
.map_err(|err| hresult_error("ToastNotificationHistory ClearWithId failed", err)),
|
||||
HistoryOp::RemoveTag { tag, group } => {
|
||||
let tag_value = tag.as_str();
|
||||
let tag = HSTRING::from(tag_value);
|
||||
let group = HSTRING::from(group.as_deref().unwrap_or(tag_value));
|
||||
history
|
||||
.RemoveGroupedTagWithId(&tag, &group, &aumid)
|
||||
.map_err(|err| {
|
||||
hresult_error(
|
||||
"ToastNotificationHistory RemoveGroupedTagWithId failed",
|
||||
err,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WinRtApartment;
|
||||
|
||||
impl WinRtApartment {
|
||||
fn initialize() -> Result<Self> {
|
||||
unsafe {
|
||||
RoInitialize(RO_INIT_MULTITHREADED)
|
||||
.map_err(|err| hresult_error("RoInitialize failed", err))?;
|
||||
}
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WinRtApartment {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
RoUninitialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hresult_error(label: &'static str, err: windows::core::Error) -> Error {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
format!("{label} (hr 0x{:x})", err.code().0 as u32),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod platform {
|
||||
use super::{HistoryOp, ToastSpec};
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
|
||||
pub(super) fn show_toast(_spec: &ToastSpec) -> Result<()> {
|
||||
Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
"win-toast not supported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn history_op(_aumid: &str, _op: &HistoryOp) -> Result<()> {
|
||||
Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
"win-toast not supported on this platform",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_raw_options() -> RawToastNotifyOptions {
|
||||
RawToastNotifyOptions {
|
||||
aumid: Some("com.fluxer.Desktop".to_string()),
|
||||
tag: None,
|
||||
group: None,
|
||||
expiration_time: None,
|
||||
scenario: None,
|
||||
audio: None,
|
||||
lines: Some(vec![RawToastText {
|
||||
text: Some("Fluxer".to_string()),
|
||||
hint_max_lines: None,
|
||||
hint: None,
|
||||
}]),
|
||||
images: None,
|
||||
inputs: None,
|
||||
actions: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_missing_aumid() {
|
||||
let mut raw = valid_raw_options();
|
||||
raw.aumid = None;
|
||||
let err = validate_toast_spec(raw).expect_err("missing AUMID should be rejected");
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
assert_eq!(err.reason, "aumid is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_empty_lines() {
|
||||
let mut raw = valid_raw_options();
|
||||
raw.lines = Some(vec![]);
|
||||
let err = validate_toast_spec(raw).expect_err("empty lines should be rejected");
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
assert_eq!(err.reason, "invalid toast spec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_maps_audio_keyword_and_object() {
|
||||
let mut silent = valid_raw_options();
|
||||
silent.audio = Some(RawAudio::Keyword("silent".to_string()));
|
||||
let spec = validate_toast_spec(silent).expect("silent audio should validate");
|
||||
assert!(spec.audio_silent);
|
||||
assert!(!spec.audio_loop);
|
||||
assert_eq!(spec.audio_src, None);
|
||||
|
||||
let mut custom = valid_raw_options();
|
||||
custom.audio = Some(RawAudio::Object(RawAudioObject {
|
||||
silent: Some(false),
|
||||
loop_audio: Some(true),
|
||||
src: Some("ms-appx:///tone.mp3".to_string()),
|
||||
}));
|
||||
let spec = validate_toast_spec(custom).expect("custom audio should validate");
|
||||
assert!(!spec.audio_silent);
|
||||
assert!(spec.audio_loop);
|
||||
assert_eq!(spec.audio_src.as_deref(), Some("ms-appx:///tone.mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_xml_escapes_values_and_preserves_shape() {
|
||||
let spec = ToastSpec {
|
||||
aumid: "com.fluxer.Desktop".to_string(),
|
||||
tag: Some("tag".to_string()),
|
||||
group: Some("group".to_string()),
|
||||
expiration_time: None,
|
||||
scenario: Some("reminder&urgent".to_string()),
|
||||
audio_src: Some("ms-appx:///sounds/a&b.mp3".to_string()),
|
||||
audio_silent: false,
|
||||
audio_loop: true,
|
||||
lines: vec![
|
||||
ToastTextSpec {
|
||||
text: "A < B & \"quoted\"".to_string(),
|
||||
hint_max_lines: Some(2),
|
||||
is_attribution: false,
|
||||
},
|
||||
ToastTextSpec {
|
||||
text: "via Fluxer".to_string(),
|
||||
hint_max_lines: None,
|
||||
is_attribution: true,
|
||||
},
|
||||
],
|
||||
images: vec![ToastImageSpec {
|
||||
uri: "file:///C:/a'b.png".to_string(),
|
||||
placement: Some("hero".to_string()),
|
||||
hint_crop: Some("circle".to_string()),
|
||||
alt: Some("A&B".to_string()),
|
||||
}],
|
||||
inputs: vec![ToastInputSpec {
|
||||
id: "reply".to_string(),
|
||||
r#type: "selection".to_string(),
|
||||
placeholder: Some("Say \"hi\"".to_string()),
|
||||
title: Some("Pick".to_string()),
|
||||
options: vec![ToastInputOption {
|
||||
id: "yes".to_string(),
|
||||
content: "Yes & go".to_string(),
|
||||
}],
|
||||
}],
|
||||
actions: vec![ToastActionSpec {
|
||||
label: "Open <now>".to_string(),
|
||||
args: "fluxer://open?x=1&y=2".to_string(),
|
||||
activation_type: None,
|
||||
image_uri: Some("file:///icon.png".to_string()),
|
||||
hint_input_id: Some("reply".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
build_toast_xml(&spec),
|
||||
concat!(
|
||||
"<toast scenario=\"reminder&urgent\">",
|
||||
"<visual><binding template=\"ToastGeneric\">",
|
||||
"<image src=\"file:///C:/a'b.png\" placement=\"hero\" hint-crop=\"circle\" alt=\"A&B\"/>",
|
||||
"<text hint-maxLines=\"2\">A < B & "quoted"</text>",
|
||||
"<text placement=\"attribution\">via Fluxer</text>",
|
||||
"</binding></visual>",
|
||||
"<actions>",
|
||||
"<input id=\"reply\" type=\"selection\" placeHolderContent=\"Say "hi"\" title=\"Pick\">",
|
||||
"<selection id=\"yes\" content=\"Yes & go\"/>",
|
||||
"</input>",
|
||||
"<action content=\"Open <now>\" arguments=\"fluxer://open?x=1&y=2\" activationType=\"foreground\" imageUri=\"file:///icon.png\" hint-inputId=\"reply\"/>",
|
||||
"</actions>",
|
||||
"<audio src=\"ms-appx:///sounds/a&b.mp3\" loop=\"true\"/>",
|
||||
"</toast>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_js_iso_timestamp_to_windows_ticks() {
|
||||
assert_eq!(
|
||||
parse_rfc3339_utc_100ns("1970-01-01T00:00:00.000Z"),
|
||||
Some(116_444_736_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_rfc3339_utc_100ns("1970-01-01T01:30:00.1234567+01:00"),
|
||||
Some(116_444_754_001_234_567)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_iso_timestamp() {
|
||||
assert_eq!(parse_rfc3339_utc_100ns("2026-02-29T00:00:00Z"), None);
|
||||
assert_eq!(parse_rfc3339_utc_100ns("2026-01-01 00:00:00Z"), None);
|
||||
assert_eq!(parse_rfc3339_utc_100ns("2026-01-01T00:00:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_validation_rejects_missing_tag() {
|
||||
let raw = RawDismissOptions {
|
||||
aumid: Some("com.fluxer.Desktop".to_string()),
|
||||
tag: None,
|
||||
group: None,
|
||||
};
|
||||
let err = require_non_empty(raw.tag, "tag is required for dismiss")
|
||||
.expect_err("missing tag should be rejected");
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
assert_eq!(err.reason, "tag is required for dismiss");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn non_windows_worker_preserves_stub_error_contract() {
|
||||
let spec = validate_toast_spec(valid_raw_options()).expect("options should validate");
|
||||
let err = platform::show_toast(&spec).expect_err("non-Windows should fail");
|
||||
assert_eq!(err.status, Status::GenericFailure);
|
||||
assert_eq!(err.reason, "win-toast not supported on this platform");
|
||||
|
||||
let err = platform::history_op("com.fluxer.Desktop", &HistoryOp::Clear)
|
||||
.expect_err("non-Windows should fail");
|
||||
assert_eq!(err.status, Status::GenericFailure);
|
||||
assert_eq!(err.reason, "win-toast not supported on this platform");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user