diff --git a/samples/SkiaMultiplatformSample/src/awtMain/kotlin/org/jetbrains/skiko/sample/App.awt.kt b/samples/SkiaMultiplatformSample/src/awtMain/kotlin/org/jetbrains/skiko/sample/App.awt.kt index b63417654..37eb7ee54 100644 --- a/samples/SkiaMultiplatformSample/src/awtMain/kotlin/org/jetbrains/skiko/sample/App.awt.kt +++ b/samples/SkiaMultiplatformSample/src/awtMain/kotlin/org/jetbrains/skiko/sample/App.awt.kt @@ -1,11 +1,13 @@ package org.jetbrains.skiko.sample import org.jetbrains.skiko.* +import org.jetbrains.skiko.windows.* import java.awt.Dimension import javax.swing.* -fun main() { +fun main(args: Array) { val skiaLayer = SkiaLayer() + parseArgs(args) val app = run { //EmojiStory() AwtClocks(skiaLayer) @@ -20,5 +22,70 @@ fun main() { skiaLayer.needRedraw() window.pack() window.isVisible = true + createWindowsJumpList() + } +} + +/** + * The Jump List sample requires a separate executable for two reasons: + * 1. The java.exe process somehow blocks the Jump Lists so to enable them we would have to provide + * an AppUserModelID for the sample's window, and + * 2. In the current implementation, the Jump List items launch the calling process when clicked with + * command line arguments provided to the JumpListItem. Without a separate executable, + * java.exe will be launched. + */ +private fun createWindowsJumpList() { + // Check if Jump Lists are supported + if (JumpList.isSupported()) { + // Start a Jump List building transaction + JumpList.build { + // If the process has an explicit AppUserModelID, it should be set for the Jump List + // before calling [beginList] + // setAppID("org.jetbrains.skiko.SkiaMultiplatformSample.awt") + + // Create a new Jump List + beginList() + + // Collect elements to add to the Jump List, e.g. some recent documents + val recentItems = listOf( + JumpListItem("Recent item 1", "path\\to\\recent\\item\\1").apply { + attributes = JumpListItemAttributes().apply { + description = "The first recent item" + icon = JumpListItemIcon("explorer.exe", 0) + } + } + ) + + // TODO: Exclude [removedItems] from [recentItems] + val removedItems = getRemovedItems() + + // Add recent items to the Jump List + addCategory("Recent items", recentItems) + + // Add a user task to the Jump List + addUserTask(JumpListItem("User Task 1", "--user-task-1")) + + // Commit the Jump List and finish the transaction + try { + commit().onFailure { + println("The Jump List has been committed but appending categories failed. Please make sure Jump Lists are enabled for the user.") + } + } + catch (e: JumpListException) { + println("Couldn't build the Jump List, most likely because the sample is run via java.exe. ${e.message}") + } + } + } +} + +// When a Jump List item is clicked, the app is re-launched with an argument +// passed to the JumpListItem's constructor +private fun parseArgs(args: Array) { + for(arg in args) { + when (arg) { + "--user-task-1" -> println("User Task 1 in the Jump List was clicked!") + "path\\to\\recent\\item\\1" -> println("Recent Item 1 in the Jump List was clicked!") + else -> println("Unknown argument: ($arg)") + } } } diff --git a/skiko/buildSrc/src/main/kotlin/tasks/configuration/JvmTasksConfiguration.kt b/skiko/buildSrc/src/main/kotlin/tasks/configuration/JvmTasksConfiguration.kt index 6cb0f7bbd..a4982f6f0 100644 --- a/skiko/buildSrc/src/main/kotlin/tasks/configuration/JvmTasksConfiguration.kt +++ b/skiko/buildSrc/src/main/kotlin/tasks/configuration/JvmTasksConfiguration.kt @@ -24,6 +24,7 @@ import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.withType +import org.gradle.util.internal.VersionNumber import projectDirs import registerOrGetSkiaDirProvider import registerSkikoTask @@ -295,6 +296,16 @@ fun SkikoProjectContext.createLinkJvmBindings( "/ignore:4217" ) ) + // workaround for VS Build Tools 2022 (17.2+) change + // https://developercommunity.visualstudio.com/t/-imp-std-init-once-complete-unresolved-external-sy/1684365#T-N10041864 + if (windowsSdkPaths.toolchainVersion >= VersionNumber.parse("14.32")) { + addAll( + arrayOf( + "/ALTERNATENAME:__imp___std_init_once_begin_initialize=__imp_InitOnceBeginInitialize", + "/ALTERNATENAME:__imp___std_init_once_complete=__imp_InitOnceComplete" + ) + ) + } addAll( arrayOf( "/NOLOGO", @@ -302,6 +313,8 @@ fun SkikoProjectContext.createLinkJvmBindings( "Advapi32.lib", "gdi32.lib", "Dwmapi.lib", + "ole32.lib", + "Propsys.lib", "shcore.lib", "user32.lib", ) diff --git a/skiko/buildSrc/src/main/kotlin/windowsSdkPaths.kt b/skiko/buildSrc/src/main/kotlin/windowsSdkPaths.kt index d16bf99a2..909d0ecc8 100644 --- a/skiko/buildSrc/src/main/kotlin/windowsSdkPaths.kt +++ b/skiko/buildSrc/src/main/kotlin/windowsSdkPaths.kt @@ -2,8 +2,10 @@ import org.gradle.api.invocation.Gradle import org.gradle.kotlin.dsl.support.serviceOf import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform.* import org.gradle.nativeplatform.platform.internal.NativePlatformInternal +import org.gradle.nativeplatform.toolchain.internal.EmptySystemLibraries import org.gradle.nativeplatform.toolchain.internal.SystemLibraries import org.gradle.nativeplatform.toolchain.internal.msvcpp.* +import org.gradle.util.internal.VersionNumber import java.io.File import kotlin.math.abs @@ -12,6 +14,7 @@ data class WindowsSdkPaths( val linker: File, val includeDirs: Collection, val libDirs: Collection, + val toolchainVersion: VersionNumber, ) private const val ENV_SKIKO_VSBT_PATH = "SKIKO_VSBT_PATH" @@ -26,12 +29,14 @@ fun findWindowsSdkPaths(gradle: Gradle, arch: Arch): WindowsSdkPaths { val visualCpp = finder.findVisualCpp() val windowsSdk = finder.findWindowsSdk() val ucrt = finder.findUcrt() - val systemLibraries = listOf(visualCpp, windowsSdk, ucrt) + val winrt = finder.findWinrt() + val systemLibraries = listOf(visualCpp, windowsSdk, ucrt, winrt) return WindowsSdkPaths( compiler = visualCpp.compilerExecutable.fixPathFor(arch), linker = visualCpp.linkerExecutable.fixPathFor(arch), includeDirs = systemLibraries.flatMap { it.includeDirs }.map { it.fixPathFor(arch) }, libDirs = systemLibraries.flatMap { it.libDirs }.map { it.fixPathFor(arch) }, + toolchainVersion = visualCpp.implementationVersion, ) } @@ -92,6 +97,15 @@ private class GradleWindowsComponentFinderWrapper( ?: error("UCRT component for host platform '$hostPlatform' is null") } + fun findWinrt(): SystemLibraries { + // Gradle doesn't have a Locator for WinRT, so we take the UCRT one and fix the path + return object : EmptySystemLibraries() { + override fun getIncludeDirs(): List { + return findUcrt().includeDirs.map { File(it.path.replace("ucrt", "winrt")) } + } + } + } + private fun List.chooseComponentByPreferredVersion( componentType: String, preferredVersionEnvVar: String, diff --git a/skiko/src/awtMain/cpp/include/jni_helpers.h b/skiko/src/awtMain/cpp/include/jni_helpers.h index a453432da..e39af9ab4 100644 --- a/skiko/src/awtMain/cpp/include/jni_helpers.h +++ b/skiko/src/awtMain/cpp/include/jni_helpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include template @@ -8,3 +9,10 @@ T inline fromJavaPointer(jlong ptr) { return reinterpret_cast(static_cast jlong inline toJavaPointer(T ptr) { return static_cast(reinterpret_cast(ptr)); } + +std::wstring inline toStdString(JNIEnv *env, jstring jstr) { + const jchar* jchars = env->GetStringCritical(jstr, NULL); + std::wstring wstr = reinterpret_cast(jchars); + env->ReleaseStringCritical(jstr, jchars); + return wstr; +} diff --git a/skiko/src/awtMain/cpp/windows/JumpList.cc b/skiko/src/awtMain/cpp/windows/JumpList.cc new file mode 100644 index 000000000..9cf559698 --- /dev/null +++ b/skiko/src/awtMain/cpp/windows/JumpList.cc @@ -0,0 +1,345 @@ +#if SK_BUILD_FOR_WIN + +#include +#include +#include +#include +#include + +#include + +#include "jni_helpers.h" + +template +using ComPtr = Microsoft::WRL::ComPtr; + +#define THROW_IF_FAILED(action, message, ret) \ + do { \ + HRESULT hr { action }; \ + if (FAILED(hr)) { \ + throwJumpListException(env, __FUNCTION__, DWORD(hr), message); \ + return ret; \ + } \ + } while ((void)0, 0) + +namespace org::jetbrains::skiko::windows { + namespace JumpListInteropItem { + jclass cls; + jmethodID init; + jmethodID getTitle; + jmethodID getArguments; + jmethodID getDescription; + jmethodID getIconPath; + jmethodID getIconNum; + } + + void ensure(JNIEnv* env) { + static jclass _cls = JumpListInteropItem::cls = (jclass) env->NewGlobalRef(env->FindClass("org/jetbrains/skiko/windows/JumpListBuilder$JumpListInteropItem")); + static jmethodID _init = JumpListInteropItem::init = env->GetMethodID(_cls, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V"); + static jmethodID _getTitle = JumpListInteropItem::getTitle = env->GetMethodID(_cls, "getTitle", "()Ljava/lang/String;"); + static jmethodID _getArguments = JumpListInteropItem::getArguments = env->GetMethodID(_cls, "getArguments", "()Ljava/lang/String;"); + static jmethodID _getDescription = JumpListInteropItem::getDescription = env->GetMethodID(_cls, "getDescription", "()Ljava/lang/String;"); + static jmethodID _getIconPath = JumpListInteropItem::getIconPath = env->GetMethodID(_cls, "getIconPath", "()Ljava/lang/String;"); + static jmethodID _getIconNum = JumpListInteropItem::getIconNum = env->GetMethodID(_cls, "getIconNum", "()I"); + } +} + +/** + * Convenience wrapper for PROPVARIANTs that need to be cleared. + */ +class PropVariantWrapper { + PROPVARIANT _propvar; + HRESULT _hr; +public: + PropVariantWrapper(PROPVARIANT propvar) : _propvar(propvar), _hr(S_OK) { } + PropVariantWrapper(PCWSTR pszValue) { + _hr = InitPropVariantFromString(pszValue, &_propvar); + } + ~PropVariantWrapper() { + if (SUCCEEDED(_hr)) { + PropVariantClear(&_propvar); + } + } + operator HRESULT() { + return _hr; + } + operator PROPVARIANT() { + return _propvar; + } +}; + +void throwJumpListException(JNIEnv *env, const char * function, DWORD code, const char * context) { + char fullMsg[1024]; + char *msg = 0; + FormatMessage( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS | FORMAT_MESSAGE_MAX_WIDTH_MASK, + NULL, code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR) &msg, 0, NULL + ); + int result = snprintf(fullMsg, sizeof(fullMsg) - 1, + "Native exception in [%s], code %lu: %s\nContext: %s", function, code, msg, context); + LocalFree(msg); + + static jclass cls = static_cast(env->NewGlobalRef(env->FindClass("org/jetbrains/skiko/windows/JumpListException"))); + static jmethodID init = env->GetMethodID(cls, "", "(Ljava/lang/String;I)V"); + + jthrowable throwable = (jthrowable) env->NewObject(cls, init, env->NewStringUTF(fullMsg), code); + env->Throw(throwable); +} + +/** + * Creates a CLSID_ShellLink to insert into the Jump List. + */ +void createShellLink( + JNIEnv* env, + const std::wstring& title, + const std::wstring& arguments, + const std::optional& description, + const std::optional>& icon, + IShellLinkW **ppsl) +{ + ComPtr psl; + THROW_IF_FAILED( + CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&psl)), + "Failed to create a shell link.", + ); + + // Use current executable for the shell link. Can contain a verbatim (\\?\) path so MAX_PATH might not be sufficient. + WCHAR szAppPath[1024]; + if (0 == GetModuleFileNameW(NULL, szAppPath, ARRAYSIZE(szAppPath))) { + THROW_IF_FAILED(HRESULT_FROM_WIN32(GetLastError()), "Failed to get current module's path.",); + } + + THROW_IF_FAILED(psl->SetPath(szAppPath), "Failed to set shell link's path.",); + THROW_IF_FAILED(psl->SetArguments(arguments.c_str()), "Failed to set shell link's arguments.",); + + if (description.has_value()) { + THROW_IF_FAILED(psl->SetDescription(description.value().c_str()), "Failed to set shell link's description.",); + } + + if (icon.has_value()) { + const auto& [iconPath, iconNum] = icon.value(); + THROW_IF_FAILED(psl->SetIconLocation(iconPath.c_str(), iconNum), "Failed to set shell link's icon.",); + } + + // The title property is required on Jump List items provided as an IShellLink instance. + // This value is used as the display name in the Jump List. + ComPtr pps; + THROW_IF_FAILED(psl->QueryInterface(IID_PPV_ARGS(&pps)), "Failed to cast shell link to IPropertyStore.",); + + PropVariantWrapper propvar(title.c_str()); + THROW_IF_FAILED(propvar, "Failed to create a PropVariant.",); + + THROW_IF_FAILED(pps->SetValue(PKEY_Title, propvar), "Failed to set shell link's title.",); + THROW_IF_FAILED(pps->Commit(), "Failed to commit shell link's title.",); + + THROW_IF_FAILED(psl->QueryInterface(IID_PPV_ARGS(ppsl)), "Failed to return the shell link.",); +} + +void createShellLinkFromInteropObject(JNIEnv* env, jobject obj, IShellLinkW **ppsl) { + org::jetbrains::skiko::windows::ensure(env); + + std::wstring title = toStdString(env, (jstring) env->CallObjectMethod(obj, org::jetbrains::skiko::windows::JumpListInteropItem::getTitle)); + std::wstring arguments = toStdString(env, (jstring) env->CallObjectMethod(obj, org::jetbrains::skiko::windows::JumpListInteropItem::getArguments)); + + jstring jDescription = (jstring) env->CallObjectMethod(obj, org::jetbrains::skiko::windows::JumpListInteropItem::getDescription); + jstring jIconPath = (jstring) env->CallObjectMethod(obj, org::jetbrains::skiko::windows::JumpListInteropItem::getIconPath); + int jIconNum = (int) env->CallIntMethod(obj, org::jetbrains::skiko::windows::JumpListInteropItem::getIconNum); + + std::optional description = + env->IsSameObject(jDescription, NULL) ? std::nullopt : std::make_optional(toStdString(env, jDescription)); + std::optional> icon = + env->IsSameObject(jIconPath, NULL) ? std::nullopt : std::make_optional(std::make_pair(toStdString(env, jIconPath), jIconNum)); + + return createShellLink(env, title, arguments, description, icon, ppsl); +} + +jobject createJumpListInteropItemObject(JNIEnv* env, IShellLinkW *psl) +{ + org::jetbrains::skiko::windows::ensure(env); + + ComPtr propStore; + if (SUCCEEDED(psl->QueryInterface(IID_PPV_ARGS(&propStore)))) { + PROPVARIANT propvar; + if (SUCCEEDED(propStore->GetValue(PKEY_Title, &propvar))) { + PropVariantWrapper propvarWrapper(propvar); + WCHAR szTitle[512]; + if (SUCCEEDED(PropVariantToString(propvar, szTitle, ARRAYSIZE(szTitle)))) { + WCHAR szArguments[1024]; + if (SUCCEEDED(psl->GetArguments(szArguments, ARRAYSIZE(szArguments)))) { + WCHAR szIconPath[512]; + int numIcon; + if (SUCCEEDED(psl->GetIconLocation(szIconPath, ARRAYSIZE(szIconPath), &numIcon))) { + WCHAR szDescription[1024]; + if (SUCCEEDED(psl->GetDescription(szDescription, ARRAYSIZE(szDescription)))) { + return env->NewObject( + org::jetbrains::skiko::windows::JumpListInteropItem::cls, + org::jetbrains::skiko::windows::JumpListInteropItem::init, + env->NewString(reinterpret_cast(szTitle), wcsnlen_s(szTitle, ARRAYSIZE(szTitle))), + env->NewString(reinterpret_cast(szArguments), wcsnlen_s(szArguments, ARRAYSIZE(szArguments))), + env->NewString(reinterpret_cast(szDescription), wcsnlen_s(szDescription, ARRAYSIZE(szDescription))), + env->NewString(reinterpret_cast(szIconPath), wcsnlen_s(szIconPath, ARRAYSIZE(szIconPath))), + numIcon + ); + } + } + } + } + } + } + + return NULL; +} + +bool createObjectArray(JNIEnv* env, jobjectArray jobjArray, IObjectArray **ppoa) { + jsize jobjArraySize = env->GetArrayLength(jobjArray); + if (0 == jobjArraySize) { + return false; + } + + ComPtr poc; + THROW_IF_FAILED( + CoCreateInstance(CLSID_EnumerableObjectCollection, NULL, CLSCTX_INPROC, IID_PPV_ARGS(&poc)), + "Failed to create an instance of EnumerableObjectCollection.", false + ); + + for (jsize i = 0; i < jobjArraySize; i++) { + jobject obj = env->GetObjectArrayElement(jobjArray, i); + + ComPtr psl; + createShellLinkFromInteropObject(env, obj, &psl); + + THROW_IF_FAILED(poc->AddObject(psl.Get()), "Failed to add a shell link to the collection.", false); + } + + THROW_IF_FAILED(poc->QueryInterface(IID_PPV_ARGS(ppoa)), "Failed to create an object array.", false); + + return true; +} + +extern "C" +{ + JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1init(JNIEnv* env, jobject obj) { + THROW_IF_FAILED(CoInitializeEx(NULL, COINIT_MULTITHREADED), "Failed to initialize COM apartment.", 0L); + + ComPtr pcdl; + THROW_IF_FAILED( + CoCreateInstance(CLSID_DestinationList, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pcdl)), + "Failed to create CLSID_DestinationList.", 0L + ); + + return toJavaPointer(pcdl.Detach()); + } + + JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1dispose( + JNIEnv* env, jobject obj, jlong ptr) + { + ICustomDestinationList* pcdl = fromJavaPointer(ptr); + pcdl->Release(); + CoUninitialize(); + } + + JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1setAppID(JNIEnv *env, jobject obj, jlong ptr, jstring appID) { + ComPtr pcdl { fromJavaPointer(ptr) }; + if (pcdl.Get() == NULL) { + throwJumpListException(env, __FUNCTION__, E_POINTER, "Native pointer is null."); + return; + } + + std::wstring strAppID = toStdString(env, appID); + + THROW_IF_FAILED(pcdl->SetAppID(strAppID.c_str()), "Failed to set AppUserModelID for the Jump List.",); + } + + JNIEXPORT jobjectArray JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1beginList(JNIEnv *env, jobject obj, jlong ptr) { + ComPtr pcdl { fromJavaPointer(ptr) }; + if (pcdl.Get() == NULL) { + throwJumpListException(env, __FUNCTION__, E_POINTER, "Native pointer is null."); + return NULL; + } + + UINT uMaxSlots; + ComPtr poaRemoved; + THROW_IF_FAILED( + pcdl->BeginList(&uMaxSlots, IID_PPV_ARGS(&poaRemoved)), + "Failed to BeginList.", NULL + ); + + UINT cItems; + THROW_IF_FAILED(poaRemoved->GetCount(&cItems), "Failed to get removed destinations count.", NULL); + + org::jetbrains::skiko::windows::ensure(env); + jobjectArray itemsArray = env->NewObjectArray(cItems, org::jetbrains::skiko::windows::JumpListInteropItem::cls, NULL); + + for (UINT i = 0; i < cItems; i++) { + ComPtr pslRemoved; + if (SUCCEEDED(poaRemoved->GetAt(i, IID_PPV_ARGS(&pslRemoved)))) { + jobject interopItem = createJumpListInteropItemObject(env, pslRemoved.Get()); + if (interopItem != NULL) { + env->SetObjectArrayElement(itemsArray, i, interopItem); + } + } + } + + return itemsArray; + } + + JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addUserTasks( + JNIEnv* env, jobject obj, jlong ptr, jobjectArray tasks) + { + ComPtr pcdl { fromJavaPointer(ptr) }; + if (pcdl.Get() == NULL) { + throwJumpListException(env, __FUNCTION__, E_POINTER, "Native pointer is null."); + return; + } + + ComPtr poc; + THROW_IF_FAILED( + CoCreateInstance(CLSID_EnumerableObjectCollection, NULL, CLSCTX_INPROC, IID_PPV_ARGS(&poc)), + "Failed to create an instance of EnumerableObjectCollection.", + ); + + ComPtr poa; + if (!createObjectArray(env, tasks, &poa)) { + return; + } + + THROW_IF_FAILED(pcdl->AddUserTasks(poa.Get()), "Failed to add user tasks.",); + } + + JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addCategory( + JNIEnv* env, jobject obj, jlong ptr, jstring category, jobjectArray itemsArray) + { + ComPtr pcdl { fromJavaPointer(ptr) }; + if (pcdl.Get() == NULL) { + throwJumpListException(env, __FUNCTION__, E_POINTER, "Native pointer is null."); + return; + } + + ComPtr poa; + if (!createObjectArray(env, itemsArray, &poa)) { + return; + } + + std::wstring strCategory = toStdString(env, category); + + // Items listed in the removed list may not be re-added to the Jump List during this + // list-building transaction. They should not be re-added to the Jump List until + // the user has used the item again. The AppendCategory call will fail if + // an attempt to add an item in the removed list is made. + THROW_IF_FAILED(pcdl->AppendCategory(strCategory.c_str(), poa.Get()), "Failed to append category.",); + } + + JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1commit( + JNIEnv* env, jobject obj, jlong ptr) + { + ComPtr pcdl { fromJavaPointer(ptr) }; + if (pcdl.Get() == NULL) { + throwJumpListException(env, __FUNCTION__, E_POINTER, "Native pointer is null."); + return; + } + + THROW_IF_FAILED(pcdl->CommitList(), "Failed to commit the Jump List.",); + } +} + +#endif diff --git a/skiko/src/awtMain/kotlin/org/jetbrains/skiko/windows/JumpList.kt b/skiko/src/awtMain/kotlin/org/jetbrains/skiko/windows/JumpList.kt new file mode 100644 index 000000000..3802f547b --- /dev/null +++ b/skiko/src/awtMain/kotlin/org/jetbrains/skiko/windows/JumpList.kt @@ -0,0 +1,238 @@ +package org.jetbrains.skiko.windows + +import org.jetbrains.skiko.hostOs + +/** + * Provides access to Windows Jump List features + */ +object JumpList { + /** + * Returns a value that indicates whether Jump Lists are supported. + */ + fun isSupported(): Boolean = when { + hostOs.isWindows -> true + else -> false + } + + /** + * Wraps a single Jump List building transaction. + * + * The transaction starts with a call to [JumpListBuilder.beginList] and is finalised + * by a call to [JumpListBuilder.commit]. + */ + fun build(block: JumpListBuilder.() -> R): R = when { + hostOs.isWindows -> JumpListBuilder().use { builder -> + builder.initialize() + block(builder) + } + else -> error("Jump List is only supported on Windows") + } + + /** + * Wraps a single Jump List building transaction. + * + * The transaction starts with a call to [JumpListBuilder.beginList] and is finalised + * by a call to [JumpListBuilder.commit]. + */ + suspend fun buildAsync(block: suspend JumpListBuilder.() -> R): R = when { + hostOs.isWindows -> JumpListBuilder().use { builder -> + builder.initialize() + block(builder) + } + else -> error("Jump List is only supported on Windows") + } +} + +/** + * Represents an item in the Jump List. + */ +class JumpListItem(val title: String, val arguments: String) { + var attributes: JumpListItemAttributes? = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as JumpListItem + + if (title != other.title) return false + if (arguments != other.arguments) return false + + return true + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + arguments.hashCode() + return result + } +} + +/** + * Represents optional attributes that can be added to a Jump List item. + */ +class JumpListItemAttributes { + var description: String? = null + var icon: JumpListItemIcon? = null +} + +/** + * Represents an icon that can be assigned to a Jump List item. + * + * @param path A path to the file containing the icon. + * @param index An index of the icon in the specified file. + */ +class JumpListItemIcon(val path: String, val index: Int) { + companion object { + internal fun fromParts(path: String?, num: Int): JumpListItemIcon? { + if (path.isNullOrEmpty()) return null + return JumpListItemIcon(path, num) + } + } +} + +/** + * Provides functions to build the Jump List. + * + * This is a wrapper for ICustomDestinationList Windows COM interface. + */ +class JumpListBuilder internal constructor() : AutoCloseable { + private var jumpListPointer: Long = 0L + + private var removedItems: List = listOf() + + private val userTasks: MutableList = mutableListOf() + private val categories: MutableMap> = mutableMapOf() + + /** + * Starts the Jump List building transaction. + */ + internal fun initialize() { + jumpListPointer = jumpList_init().also { ptr -> + check(ptr != 0L) { "Failed to initialize Windows jump list" } + } + } + + /** + * Specifies an optional AppUserModelID for the Jump List. + * + * This function must be called if the application has an explicit AppUserModelID. + * + * @throws JumpListException [setAppID] must be called before [beginList] + */ + fun setAppID(appID: String) { + check(jumpListPointer != 0L) { "The jump list pointer is invalid" } + jumpList_setAppID(jumpListPointer, appID) + } + + /** + * Creates a new list. + */ + fun beginList() { + check(jumpListPointer != 0L) { "The jump list pointer is invalid" } + jumpList_beginList(jumpListPointer).let { interopItems -> + removedItems = interopItems.map { it.fromInterop() } + } + } + + /** + * Returns a list of items the user has chosen to remove from their Jump List. + */ + fun getRemovedItems(): List = removedItems + + /** + * Adds a task to the Jump List. Tasks always appear in the canonical "Tasks" category + * that is displayed at the bottom of the Jump List, after all other categories. + */ + fun addUserTask(task: JumpListItem) { + check(jumpListPointer != 0L) { "The jump list pointer is invalid" } + userTasks.add(task.toInterop()) + } + + /** + * Adds a category to the Jump List. If there are more categories, they will appear + * from top to bottom in the order they are appended. + * + * Make sure to exclude removed items returned by the [beginList] call as they may not be + * re-added to the list during the same list-building transaction. + */ + fun addCategory(category: String, items: List) { + check(jumpListPointer != 0L) { "The jump list pointer is invalid" } + items.map { it.toInterop() }.let { interopItems -> + categories.merge(category, interopItems) { existing, new -> existing + new } + } + } + + /** + * Finalises the Jump List building transaction by committing the list. + * + * @return [Result.success] if the list has been committed successfully. [Result.failure] if there was + * a recoverable error when committing the list. In the latter case, the list was still committed, but + * might lack some or all categories. + * + * @throws JumpListException An attempt to add an item in the removed list was made (see [addCategory]) + */ + fun commit(): Result { + check(jumpListPointer != 0L) { "The jump list pointer is invalid" } + jumpList_addUserTasks(jumpListPointer, userTasks.toTypedArray()) + val result = try { + categories.forEach { (name, items) -> + jumpList_addCategory(jumpListPointer, name, items.toTypedArray()) + } + Result.success(Unit) + } + catch (e: JumpListException) { + // According to the documentation, in case if AppendCategory fails with E_ACCESSDENIED, + // the application should continue to update tasks and call CommitList. + if (e.code != -2147024891) { + throw e + } + Result.failure(e) + } + jumpList_commit(jumpListPointer) + return result + } + + /** + * Frees the Jump List's native resources. + */ + override fun close() { + if (jumpListPointer != 0L) { + jumpList_dispose(jumpListPointer) + } + jumpListPointer = 0L + } + + private external fun jumpList_init(): Long + private external fun jumpList_dispose(ptr: Long) + + private external fun jumpList_setAppID(ptr: Long, appID: String) + private external fun jumpList_beginList(ptr: Long): Array + private external fun jumpList_addUserTasks(ptr: Long, tasks: Array) + private external fun jumpList_addCategory(ptr: Long, category: String, itemsArray: Array) + + private external fun jumpList_commit(ptr: Long) + + private class JumpListInteropItem(val title: String, + val arguments: String, + val description: String?, + val iconPath: String?, + val iconNum: Int) + + private fun JumpListItem.toInterop() = JumpListInteropItem( + title, arguments, attributes?.description, attributes?.icon?.path, attributes?.icon?.index ?: 0 + ) + + private fun JumpListInteropItem.fromInterop() = JumpListItem(title, arguments) + .also { item -> + item.attributes = when { + iconPath.isNullOrEmpty() && description.isNullOrEmpty() -> null + else -> JumpListItemAttributes().also { attr -> + attr.description = description + attr.icon = JumpListItemIcon.fromParts(iconPath, iconNum) + } + } + } +} + +class JumpListException(message: String, val code: Int) : RuntimeException(message) diff --git a/skiko/src/jvmMain/cpp/common/stubs.cc b/skiko/src/jvmMain/cpp/common/stubs.cc index 81d0357ae..4e04ab336 100644 --- a/skiko/src/jvmMain/cpp/common/stubs.cc +++ b/skiko/src/jvmMain/cpp/common/stubs.cc @@ -79,6 +79,38 @@ JNIEXPORT void JNICALL Java_org_jetbrains_skiko_redrawer_Direct3DRedrawer_initFe JNIEnv *env, jobject redrawer, jlong devicePtr) { skikoUnimplemented("Java_org_jetbrains_skiko_redrawer_Direct3DRedrawer_initFence"); } + +JNIEXPORT jlong JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1init(JNIEnv *env, jobject obj) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1init"); + return 0; +} + +JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1dispose(JNIEnv *env, jobject obj, jlong ptr) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1dispose"); +} + +JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1setAppID(JNIEnv *env, jobject obj, jlong ptr, jstring appID) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1setAppID"); +} + +JNIEXPORT jobjectArray JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1beginList(JNIEnv *env, jobject obj, jlong ptr) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1beginList"); + return 0; +} + +JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addUserTasks( + JNIEnv *env, jobject obj, jlong ptr, jobjectArray tasks) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addUserTasks"); +} + +JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addCategory( + JNIEnv *env, jobject obj, jlong ptr, jstring category, jobjectArray itemsArray) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1addCategory"); +} + +JNIEXPORT void JNICALL Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1commit(JNIEnv *env, jobject obj, jlong ptr) { + skikoUnimplemented("Java_org_jetbrains_skiko_windows_JumpListBuilder_jumpList_1commit"); +} #endif