diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..757b992 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: Build and Package ClamshellMode + +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Build ClamshellMode + run: msbuild ClamshellMode.sln /p:Configuration=Release /p:Platform=x64 + timeout-minutes: 30 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: x64/Release/ClamshellMode.exe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eb0f0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vs/* +x64/* +ClamshellMode/x64/* diff --git a/ClamshellMode.sln b/ClamshellMode.sln new file mode 100644 index 0000000..1a5e8ac --- /dev/null +++ b/ClamshellMode.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ClamshellMode", "ClamshellMode\ClamshellMode.vcxproj", "{00D17E35-CC72-4AB6-BD74-0B71D77D7440}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|ARM = Debug|ARM + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|ARM = Release|ARM + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|ARM.ActiveCfg = Debug|ARM + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|ARM.Build.0 = Debug|ARM + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|x64.ActiveCfg = Debug|x64 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|x64.Build.0 = Debug|x64 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|x86.ActiveCfg = Debug|Win32 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Debug|x86.Build.0 = Debug|Win32 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|ARM.ActiveCfg = Release|ARM + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|ARM.Build.0 = Release|ARM + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|x64.ActiveCfg = Release|x64 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|x64.Build.0 = Release|x64 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|x86.ActiveCfg = Release|Win32 + {00D17E35-CC72-4AB6-BD74-0B71D77D7440}.Release|x86.Build.0 = Release|Win32 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E8A4C49E-8E9F-4930-A59C-89443A5446D3} + EndGlobalSection +EndGlobal diff --git a/ClamshellMode/ClamshellMode.vcxproj b/ClamshellMode/ClamshellMode.vcxproj new file mode 100644 index 0000000..4da29cc --- /dev/null +++ b/ClamshellMode/ClamshellMode.vcxproj @@ -0,0 +1,193 @@ + + + + + Debug + ARM + + + Debug + Win32 + + + Release + ARM + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {00d17e35-cc72-4ab6-bd74-0b71d77d7440} + ClamshellMode + 10.0 + ClamshellMode + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + Application + true + v143 + Unicode + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + + + + + + + Level3 + true + WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + + + Console + true + + + + + Level3 + true + true + true + WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + + + Console + true + true + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdc17 + + + Console + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdc17 + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + + + Windows + true + true + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + + + Windows + true + true + true + + + + + + + + + \ No newline at end of file diff --git a/ClamshellMode/ClamshellMode.vcxproj.filters b/ClamshellMode/ClamshellMode.vcxproj.filters new file mode 100644 index 0000000..decff76 --- /dev/null +++ b/ClamshellMode/ClamshellMode.vcxproj.filters @@ -0,0 +1,22 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + \ No newline at end of file diff --git a/ClamshellMode/ClamshellMode.vcxproj.user b/ClamshellMode/ClamshellMode.vcxproj.user new file mode 100644 index 0000000..88a5509 --- /dev/null +++ b/ClamshellMode/ClamshellMode.vcxproj.user @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ClamshellMode/main.c b/ClamshellMode/main.c new file mode 100644 index 0000000..e8725d5 --- /dev/null +++ b/ClamshellMode/main.c @@ -0,0 +1,326 @@ +#include +#include +#include +#include +#include + +#pragma comment(lib, "powrprof.lib") + +#define ID_TRAY_EXIT 1001 +#define ID_TRAY_SET_DISPLAY 1002 +#define ID_COMBOBOX 1003 +#define ID_OK_BUTTON 1004 +#define WINDOW_NAME L"Disable sleep when lid closed with external display connected" +#define INI_FILE_NAME L"ClamshellMode.ini" +#define INI_KEY_NAME L"InternalDisplayID" + +static WCHAR INI_PATH[MAX_PATH] = L""; +static WCHAR INTERNAL_DISPLAY_ID[MAX_PATH] = L""; + +static void LoadDisplayIDFromIni() { + GetPrivateProfileStringW(L"Settings", INI_KEY_NAME, L"", INTERNAL_DISPLAY_ID, MAX_PATH, INI_PATH); +} + +static BOOL IsDisplayDeviceActive(const WCHAR* deviceID) { + DISPLAY_DEVICE device = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD i = 0; + + while (EnumDisplayDevicesW(NULL, i++, &device, 0) != 0) { + DISPLAY_DEVICE device2 = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD j = 0; + + while (EnumDisplayDevicesW(device.DeviceName, j++, &device2, 0) != 0) { + if (wcscmp(device2.DeviceID, deviceID) == 0) { + return (device2.StateFlags & DISPLAY_DEVICE_ACTIVE) != 0; + } + } + } + + return FALSE; +} + +static DWORD SaveDisplayIDToIni(const WCHAR* displayID) { + if (!IsDisplayDeviceActive(displayID)) { + return 1; + } + wcscpy_s(INTERNAL_DISPLAY_ID, sizeof(INTERNAL_DISPLAY_ID) / sizeof(WCHAR), displayID); + WritePrivateProfileStringW(L"Settings", INI_KEY_NAME, INTERNAL_DISPLAY_ID, INI_PATH); + return 0; +} + +static HRESULT SetINIPath() { + HRESULT r = 0; + if (SUCCEEDED(r = SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, 0, INI_PATH))) { + wcscat_s(INI_PATH, MAX_PATH, L"\\"); + wcscat_s(INI_PATH, MAX_PATH, INI_FILE_NAME); + } + return r; +} + +static GUID* GetActivePowerScheme() { + GUID* activeScheme = NULL; + if (PowerGetActiveScheme(NULL, &activeScheme) != ERROR_SUCCESS) { + printf("Failed to get active power scheme.\n"); + return NULL; + } + return activeScheme; +} + +static void _SetLidAction(GUID* scheme, const DWORD newVal) { + GUID subgroupGUID = GUID_SYSTEM_BUTTON_SUBGROUP; + GUID powerSettingGUID = GUID_LIDCLOSE_ACTION; + + // Set DC value index + if (PowerWriteDCValueIndex(NULL, scheme, &subgroupGUID, &powerSettingGUID, newVal) != ERROR_SUCCESS) { + printf("Failed to set DC value index.\n"); + } + + // Set AC value index + if (PowerWriteACValueIndex(NULL, scheme, &subgroupGUID, &powerSettingGUID, newVal) != ERROR_SUCCESS) { + printf("Failed to set AC value index.\n"); + } + + // Apply changes + if (PowerSetActiveScheme(NULL, scheme) != ERROR_SUCCESS) { + printf("Failed to set active power scheme.\n"); + } + + printf("Lid action set successfully.\n"); +} + +static void SetLidAction(const BOOL sleep) { + GUID* activeScheme = GetActivePowerScheme(); + if (activeScheme != NULL) { + _SetLidAction(activeScheme, sleep); + LocalFree(activeScheme); + } +} + +static BOOL ExternalDisplayConnected(const WCHAR* internalDisplayDeviceID) { + DISPLAY_DEVICE device = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD i = 0; + + while (EnumDisplayDevicesW(NULL, i++, &device, 0) != 0) { + DISPLAY_DEVICE device2 = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD j = 0; + + while (EnumDisplayDevicesW(device.DeviceName, j++, &device2, 0) != 0) { + if (wcscmp(device2.DeviceID, internalDisplayDeviceID) == 0) { + continue; + } + if (device2.StateFlags & DISPLAY_DEVICE_ACTIVE) { + return TRUE; + } + } + } + + return FALSE; +} + +static void PopulateDisplayDevices(HWND hComboBox) { + DISPLAY_DEVICE device = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD i = 0; + while (EnumDisplayDevicesW(NULL, i++, &device, 0) != 0) { + DISPLAY_DEVICE device2 = { .cb = sizeof(DISPLAY_DEVICE) }; + DWORD j = 0; + while (EnumDisplayDevicesW(device.DeviceName, j++, &device2, 0) != 0) { + if (device2.StateFlags & DISPLAY_DEVICE_ACTIVE) { + SendMessageW(hComboBox, CB_ADDSTRING, 0, (LPARAM)device2.DeviceID); + } + } + } +} + +static LRESULT CALLBACK WindowProcedure(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + static NOTIFYICONDATA nid = { 0 }; + static HWND hComboBox = NULL; + static HICON hIcon = NULL; // Icon handle for reuse + + switch (msg) { + case WM_CREATE: + hIcon = LoadIconW(GetModuleHandleW(L"shell32.dll"), MAKEINTRESOURCEW(284)); + nid.cbSize = sizeof(NOTIFYICONDATA); + nid.hWnd = hwnd; + nid.uID = 1; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + nid.uCallbackMessage = WM_APP + 1; + nid.hIcon = hIcon; + wcscpy_s(nid.szTip, sizeof(nid.szTip) / sizeof(nid.szTip[0]), WINDOW_NAME); + Shell_NotifyIconW(NIM_ADD, &nid); + LoadDisplayIDFromIni(); // Load the display ID on startup + + // Check if the INI file exists, if not, prompt for display selection + WCHAR iniPath[MAX_PATH] = L""; + if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, 0, iniPath))) { + wcscat_s(iniPath, MAX_PATH, L"\\"); + wcscat_s(iniPath, MAX_PATH, INI_FILE_NAME); + if (_waccess(iniPath, 0) == -1) { + PostMessageW(hwnd, WM_COMMAND, ID_TRAY_SET_DISPLAY, 0); + } + + } + break; + + case WM_APP + 1: + if (LOWORD(lParam) == WM_RBUTTONDOWN) { + POINT cursor; + GetCursorPos(&cursor); + HMENU hMenu = CreatePopupMenu(); + AppendMenuW(hMenu, MF_STRING, ID_TRAY_SET_DISPLAY, L"Select internal display..."); + AppendMenuW(hMenu, MF_STRING, ID_TRAY_EXIT, L"Exit"); + SetForegroundWindow(hwnd); + TrackPopupMenu(hMenu, TPM_RIGHTBUTTON, cursor.x, cursor.y, 0, hwnd, NULL); + DestroyMenu(hMenu); + } + break; + + case WM_COMMAND: + if (LOWORD(wParam) == ID_TRAY_EXIT) { + PostQuitMessage(0); + } + else if (LOWORD(wParam) == ID_TRAY_SET_DISPLAY) { + HWND hDisplayWindow = CreateWindowExW(0, L"DISPLAY_WINDOW_CLASS", L"Select Internal Display", + WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME & ~WS_MAXIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 400, 130, + NULL, NULL, GetModuleHandleW(NULL), NULL); + SendMessageW(hDisplayWindow, WM_SETICON, ICON_SMALL, (LPARAM)hIcon); // Set the small icon + SendMessageW(hDisplayWindow, WM_SETICON, ICON_BIG, (LPARAM)hIcon); // Set the big icon + ShowWindow(hDisplayWindow, SW_SHOW); + } + else if (LOWORD(wParam) == ID_COMBOBOX && HIWORD(wParam) == CBN_SELCHANGE) { + WPARAM index = SendMessageW(hComboBox, CB_GETCURSEL, 0, 0); + SendMessageW(hComboBox, CB_GETLBTEXT, index, (LPARAM)INTERNAL_DISPLAY_ID); + } + break; + + case WM_DISPLAYCHANGE: + if (ExternalDisplayConnected(INTERNAL_DISPLAY_ID)) { + SetLidAction(FALSE); + } + else { + SetLidAction(TRUE); + } + break; + + case WM_DESTROY: + Shell_NotifyIcon(NIM_DELETE, &nid); + if (hIcon) { + DestroyIcon(hIcon); // Clean up the icon + } + PostQuitMessage(0); + break; + + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + return 0; +} + +static LRESULT CALLBACK DisplayWindowProcedure(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + static HWND hComboBox = NULL; + static WCHAR currentDisplayID[MAX_PATH] = L""; + + switch (msg) { + case WM_CREATE: + hComboBox = CreateWindowW(L"COMBOBOX", NULL, + CBS_DROPDOWN | WS_CHILD | WS_VISIBLE, + 10, 10, 360, 120, hwnd, (HMENU)ID_COMBOBOX, + GetModuleHandleW(NULL), NULL); + PopulateDisplayDevices(hComboBox); + + CreateWindowW(L"BUTTON", L"OK", + WS_TABSTOP | WS_VISIBLE | WS_CHILD | BS_DEFPUSHBUTTON, + 140, 50, 100, 30, hwnd, (HMENU)ID_OK_BUTTON, + GetModuleHandleW(NULL), NULL); + break; + + case WM_COMMAND: + if (LOWORD(wParam) == ID_COMBOBOX && HIWORD(wParam) == CBN_SELCHANGE) { + WPARAM index = SendMessageW(hComboBox, CB_GETCURSEL, 0, 0); + SendMessageW(hComboBox, CB_GETLBTEXT, index, (LPARAM)currentDisplayID); + } + else if (LOWORD(wParam) == ID_OK_BUTTON) { + if (SaveDisplayIDToIni(currentDisplayID) == 0) { + currentDisplayID[0] = L'\0'; + ShowWindow(hwnd, SW_HIDE); // Hide the window instead of destroying it + } + else { + MessageBoxW(hwnd, L"Display not found!", L"Error", MB_ICONERROR | MB_OK); + } + } + break; + + case WM_SIZE: + InvalidateRect(hwnd, NULL, TRUE); + break; + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + FillRect(hdc, &ps.rcPaint, (HBRUSH)(COLOR_WINDOW + 1)); + EndPaint(hwnd, &ps); + break; + } + + case WM_DESTROY: + if (wcslen(INTERNAL_DISPLAY_ID) == 0) { + PostQuitMessage(0); // Exit only if INI file does not exist + } + else { + ShowWindow(hwnd, SW_HIDE); // Otherwise, just hide the window + } + break; + + case WM_DISPLAYCHANGE: + SendMessage(hComboBox, CB_RESETCONTENT, 0, 0); + PopulateDisplayDevices(hComboBox); + break; + + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + return 0; +} + +int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR lpCmdLine, int nCmdShow) +{ + HANDLE mutex = CreateMutexW(NULL, TRUE, L"Global\\ClamshellMode"); + if (GetLastError() == ERROR_ALREADY_EXISTS || mutex == NULL) { + return 1; + } + + const WCHAR className[] = L"ClamshellModeWindow"; + const WCHAR displayClassName[] = L"DISPLAY_WINDOW_CLASS"; + + if (!SUCCEEDED(SetINIPath())) { + return 1; + } + + WNDCLASS wc = { 0 }; + wc.lpfnWndProc = WindowProcedure; + wc.hInstance = hInstance; + wc.lpszClassName = className; + RegisterClassW(&wc); + + wc.lpfnWndProc = DisplayWindowProcedure; + wc.lpszClassName = displayClassName; + wc.hIcon = LoadIconW(GetModuleHandleW(L"shell32.dll"), MAKEINTRESOURCEW(284)); // Set the icon for display window + RegisterClassW(&wc); + + HWND hwnd = CreateWindowExW(0, className, WINDOW_NAME, + 0, CW_USEDEFAULT, CW_USEDEFAULT, + 0, 0, NULL, NULL, hInstance, NULL); + + MSG msg = { 0 }; + while (GetMessageW(&msg, NULL, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + CloseHandle(mutex); + return 0; +} + +int main(void) { + return WinMain(GetModuleHandleW(NULL), NULL, NULL, 0); +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..aef93ce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2024 Arkadii Chekha + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9344c6c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Clamshell Mode for Windows Laptops +This tool keeps the laptop awake when the lid is closed if an external display is connected, and puts it to sleep when the external display is disconnected. It mimics the Clamshell Mode feature found in MacBooks. + +### How to Use? +You can either build the Visual Studio project yourself or download a build generated by GitHub Actions from the Releases section. Place the executable in the `shell:startup` directory (which can be opened in Explorer). After the first launch, it will ask for the internal display DeviceID. + +To simplify the DeviceID selection process, it is advisable to disconnect all external monitors before launching the application. This will ensure that only one DeviceID appears in the selection menu, corresponding to the internal display. \ No newline at end of file