diff --git a/.github/workflows/shared-testing-linux.yml b/.github/workflows/shared-testing-linux.yml index a85cc3dfb..0c710d025 100644 --- a/.github/workflows/shared-testing-linux.yml +++ b/.github/workflows/shared-testing-linux.yml @@ -118,6 +118,15 @@ jobs: eval "$(dbus-launch --sh-syntax)" + export GDK_BACKEND=x11 + export NO_AT_BRIDGE=1 + export GALLIUM_DRIVER=llvmpipe + export LIBGL_ALWAYS_SOFTWARE=1 + export LIBGL_DRI3_DISABLE=1 + export MESA_LOADER_DRIVER_OVERRIDE=llvmpipe + export WEBKIT_DISABLE_COMPOSITING_MODE=1 + export WEBKIT_DISABLE_DMABUF_RENDERER=1 + echo "Launching Xvfb..." Xvfb :99 \ -screen 0 1920x1080x24 \ @@ -176,7 +185,19 @@ jobs: dotnet test --solution InfiniFrame.GitHubActions.Testing.slnf \ --configuration Release \ --no-build \ - --no-restore + --no-restore \ + -m:1 \ + /p:TestTfmsInParallel=false || { + test_exit=$? + echo "=== Test command failed with exit code ${test_exit} ===" + echo "=== Xvfb log ===" + cat xvfb.log || true + echo "=== Mutter log ===" + cat mutter.log || true + echo "=== Process snapshot ===" + ps aux | grep -E '([X]vfb|[m]utter|[d]otnet|[W]ebKit)' || true + exit "${test_exit}" + } - name: Pack Tool E2E if: always() diff --git a/.github/workflows/shared-testing-windows-playwright.yml b/.github/workflows/shared-testing-windows-playwright.yml index cbcb96fd7..52d2f404a 100644 --- a/.github/workflows/shared-testing-windows-playwright.yml +++ b/.github/workflows/shared-testing-windows-playwright.yml @@ -126,16 +126,6 @@ jobs: cache: 'npm' cache-dependency-path: '**/package-lock.json' - - name: Prepare WebView2 Cache Directory - shell: pwsh - run: New-Item -ItemType Directory -Path ".cache\webview2" -Force | Out-Null - - - name: Cache WebView2 - uses: actions/cache@v5 - with: - path: .cache\webview2 - key: webview2-installer-${{ runner.os }}-v1 - - name: Install WebView2 Runtime shell: pwsh run: | diff --git a/InfiniFrame.slnx b/InfiniFrame.slnx index 464c22b2b..b4ac416fe 100644 --- a/InfiniFrame.slnx +++ b/InfiniFrame.slnx @@ -82,6 +82,7 @@ + diff --git a/docker/infiniframe-linux/common.sh b/docker/infiniframe-linux/common.sh index e3c95de38..05fcbdba5 100644 --- a/docker/infiniframe-linux/common.sh +++ b/docker/infiniframe-linux/common.sh @@ -93,7 +93,14 @@ start_virtual_display() { setup_display_mode() { local xvfb_log="${1:-/tmp/xvfb.log}" local mutter_log="${2:-/tmp/mutter.log}" + export GDK_BACKEND="${GDK_BACKEND:-x11}" export NO_AT_BRIDGE="${NO_AT_BRIDGE:-1}" + export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}" + export LIBGL_ALWAYS_SOFTWARE="${LIBGL_ALWAYS_SOFTWARE:-1}" + export LIBGL_DRI3_DISABLE="${LIBGL_DRI3_DISABLE:-1}" + export MESA_LOADER_DRIVER_OVERRIDE="${MESA_LOADER_DRIVER_OVERRIDE:-llvmpipe}" + export WEBKIT_DISABLE_COMPOSITING_MODE="${WEBKIT_DISABLE_COMPOSITING_MODE:-1}" + export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}" start_dbus_session if [[ "${USE_HOST_DISPLAY}" == "1" ]]; then diff --git a/docker/infiniframe-linux/tests.sh b/docker/infiniframe-linux/tests.sh index 4773e7708..0835d9e51 100644 --- a/docker/infiniframe-linux/tests.sh +++ b/docker/infiniframe-linux/tests.sh @@ -18,4 +18,15 @@ dotnet test --solution "${SOLUTION_FILTER}" \ --no-build \ --no-restore \ /p:UseAppHost=false \ - "${COMMON_DOTNET_PROPS[@]}" + /p:TestTfmsInParallel=false \ + "${COMMON_DOTNET_PROPS[@]}" || { + test_exit=$? + echo "=== Test command failed with exit code ${test_exit} ===" + echo "=== Xvfb log ===" + cat /tmp/xvfb.log || true + echo "=== Mutter log ===" + cat /tmp/mutter.log || true + echo "=== Process snapshot ===" + ps aux | grep -E '([X]vfb|[m]utter|[d]otnet|[W]ebKit)' || true + exit "${test_exit}" + } diff --git a/docs/docs/migration/photino-backlog.md b/docs/docs/migration/photino-backlog.md index d91021239..fbf7bd2f1 100644 --- a/docs/docs/migration/photino-backlog.md +++ b/docs/docs/migration/photino-backlog.md @@ -9,6 +9,7 @@ This backlog will be used to track any remaining issues or features that need to - 🚧 : Under construction - ❓ : Under discussion / unclear if should be handled natively - ❌ : Rejected for native implementation +- 🚫 : Out of scope. Usually means this is normal behavior or not an issue with the original Photino codebase --- @@ -29,7 +30,7 @@ This backlog will be used to track any remaining issues or features that need to | ❓ | Set icon in the taskbar on windows 11 | [photino.Blazor#161](https://github.com/tryphotino/photino.Blazor/issues/161) | | | ✅ | .NET 10 support | [photino.Blazor#164](https://github.com/tryphotino/photino.Blazor/issues/164), [photino.Blazor#165](https://github.com/tryphotino/photino.Blazor/pull/165) | | | ❓ | Fix handling of app:// URLs containing a #fragment | [photino.Blazor#166](https://github.com/tryphotino/photino.Blazor/pull/166) | | -| ❓ | Top Empty window caption appear in SetChromeless(true) | [photino.Blazor#167](https://github.com/tryphotino/photino.Blazor/issues/167) | | +| 🚫 | Top Empty window caption appear in SetChromeless(true) | [photino.Blazor#167](https://github.com/tryphotino/photino.Blazor/issues/167) | | --- @@ -41,7 +42,7 @@ This backlog will be used to track any remaining issues or features that need to | ❓ | Native Menu Support | [Photino.Native#44](https://github.com/tryphotino/photino.Native/issues/44) | | | ❓ | Dedicated scheme for logging from UI to host app | [Photino.Native#45](https://github.com/tryphotino/photino.Native/issues/45) | | | ❌ | Windows x86 | [Photino.Native#53](https://github.com/tryphotino/photino.Native/issues/53) | | -| ❓ | RegisterWindowClosingHandler not working on linux (Debian) | [Photino.Native#75](https://github.com/tryphotino/photino.Native/issues/75) | | +| ✅ | RegisterWindowClosingHandler not working on linux (Debian) | [Photino.Native#75](https://github.com/tryphotino/photino.Native/issues/75) | | | ❓ | Add SingleInstanceMode to Photino initialization | [Photino.Native#111](https://github.com/tryphotino/photino.Native/issues/111) | | | ❓ | Android Support | [Photino.Native#115](https://github.com/tryphotino/photino.Native/issues/115) | | | ❓ | Stack overflow. at Photino.NET.PhotinoWindow.Photino_WaitForExit(IntPtr) | [Photino.Native#141](https://github.com/tryphotino/photino.Native/issues/141) | | @@ -62,44 +63,44 @@ This backlog will be used to track any remaining issues or features that need to ## Photino.NET -| Status | Feature | Links | InfiniFrame PR | -|:-------|:----------------------------------------------------------------------------------------------|:------------------------------------------------------------------------|:----------------------------------------------------------------------| -| ✅ | Problem with "insecure origins" | [Photino.NET#25](https://github.com/tryphotino/photino.NET/issues/25) | | -| ❓ | JS injection into WebView | [Photino.NET#58](https://github.com/tryphotino/photino.NET/issues/58) | | -| ❓ | Creating a 2nd PhotinoWindow after closing all others fails | [Photino.NET#59](https://github.com/tryphotino/photino.NET/issues/59) | | -| ❓ | Is there a way to bypass WebKits SSL check? | [Photino.NET#65](https://github.com/tryphotino/photino.NET/issues/65) | | -| ❓ | Make window transparent | [Photino.NET#73](https://github.com/tryphotino/photino.NET/issues/73) | | -| ❓ | Javascript debugging | [Photino.NET#75](https://github.com/tryphotino/photino.NET/issues/75) | | -| ❓ | Chromeless Window | [Photino.NET#80](https://github.com/tryphotino/photino.NET/issues/80) | | -| ❓ | SendWebMessage from WindowCreated handler raises a System.AccessViolationException | [Photino.NET#87](https://github.com/tryphotino/photino.NET/issues/87) | | -| ❓ | Icon failed to load | [Photino.NET#95](https://github.com/tryphotino/photino.NET/issues/95) | | -| ❓ | The application lacks a taskbar Icon | [Photino.NET#106](https://github.com/tryphotino/photino.NET/issues/106) | | -| ❓ | Possibility to hide the window from the taskbar/dock | [Photino.NET#107](https://github.com/tryphotino/photino.NET/issues/107) | | -| ❓ | Make a window only miniable | [Photino.NET#112](https://github.com/tryphotino/photino.NET/issues/112) | | -| ❓ | Interoperability between C # and JavaScript | [Photino.NET#120](https://github.com/tryphotino/photino.NET/issues/120) | | -| ✅ | Window.external is deprecated by browsers | [Photino.NET#124](https://github.com/tryphotino/photino.NET/issues/124) | | -| ❓ | Intercept navigation events | [Photino.NET#139](https://github.com/tryphotino/photino.NET/issues/139) | | -| ❓ | ShowSaveFile How to set the default file name | [Photino.NET#140](https://github.com/tryphotino/photino.NET/issues/140) | | -| ❓ | Running as administrator disables other instances | [Photino.NET#162](https://github.com/tryphotino/photino.NET/issues/162) | | -| ❌ | Mica/Fluent design window for windows | [Photino.NET#167](https://github.com/tryphotino/photino.NET/issues/167) | | -| ❓ | Retrieve current Url of PhotinoWindow | [Photino.NET#197](https://github.com/tryphotino/photino.NET/issues/197) | | -| ✅ | Ctrl+ mouse wheel to zoom the page | [Photino.NET#217](https://github.com/tryphotino/photino.NET/issues/217) | | -| ❓ | RegisterCustomSchemeHandler won't let do fetch and XMLHttpRequest requests | [Photino.NET#232](https://github.com/tryphotino/photino.NET/issues/232) | | -| ❓ | Set the background color of the native window body | [Photino.NET#239](https://github.com/tryphotino/photino.NET/issues/239) | | -| ✅ | Default csproj file uses WinExe but should use Exe so it will properly build on all platforms | [Photino.NET#240](https://github.com/tryphotino/photino.NET/issues/240) | | -| ❓ | Disable browser shortcuts | [Photino.NET#251](https://github.com/tryphotino/photino.NET/issues/251) | | -| ❓ | Fixed WebView2 runtime | [Photino.NET#254](https://github.com/tryphotino/photino.NET/issues/254) | | -| ✅ | `ILogger` implementation | [Photino.NET#257](https://github.com/tryphotino/photino.NET/issues/257) | | -| ❓ | Add complex notifications | [Photino.NET#261](https://github.com/tryphotino/photino.NET/pull/261) | | -| ❓ | Import StartDragging, StartResizing | [Photino.NET#262](https://github.com/tryphotino/photino.NET/pull/262) | | -| ❓ | Bindings for controlling taskbar progress/flash | [Photino.NET#263](https://github.com/tryphotino/photino.NET/pull/263) | | -| ❓ | Allow parent window to be set before initialized | [Photino.NET#264](https://github.com/tryphotino/photino.NET/pull/264) | | -| ❓ | Window header size | [Photino.NET#266](https://github.com/tryphotino/photino.NET/issues/266) | | -| ❓ | Optional window title 31-char-length-limitation on Linux/GTK | [Photino.NET#267](https://github.com/tryphotino/photino.NET/pull/267) | | -| ❓ | Inject Arbitrary Javascript into Webview | [Photino.NET#268](https://github.com/tryphotino/photino.NET/issues/268) | | -| ❓ | Parent vs child window behavior | [Photino.NET#269](https://github.com/tryphotino/photino.NET/issues/269) | | -| ❓ | WindowClosed event | [Photino.NET#271](https://github.com/tryphotino/photino.NET/issues/271) | | -| ✅ | SetIconFile Linux crash | [Photino.NET#272](https://github.com/tryphotino/photino.NET/issues/272) | [InfiniFrame#165](https://github.com/InfiniLore/InfiniFrame/pull/165) | +| Status | Feature | Links | InfiniFrame PR | +|:-------|:----------------------------------------------------------------------------------------------|:------------------------------------------------------------------------|:------------------------------------------------------------------------| +| ✅ | Problem with "insecure origins" | [Photino.NET#25](https://github.com/tryphotino/photino.NET/issues/25) | | +| ❓ | JS injection into WebView | [Photino.NET#58](https://github.com/tryphotino/photino.NET/issues/58) | | +| ✅ | Creating a 2nd PhotinoWindow after closing all others fails | [Photino.NET#59](https://github.com/tryphotino/photino.NET/issues/59) | | +| ❓ | Is there a way to bypass WebKits SSL check? | [Photino.NET#65](https://github.com/tryphotino/photino.NET/issues/65) | | +| ❓ | Make window transparent | [Photino.NET#73](https://github.com/tryphotino/photino.NET/issues/73) | | +| ❓ | Javascript debugging | [Photino.NET#75](https://github.com/tryphotino/photino.NET/issues/75) | | +| ❓ | Chromeless Window | [Photino.NET#80](https://github.com/tryphotino/photino.NET/issues/80) | | +| ❓ | SendWebMessage from WindowCreated handler raises a System.AccessViolationException | [Photino.NET#87](https://github.com/tryphotino/photino.NET/issues/87) | | +| ❓ | Icon failed to load | [Photino.NET#95](https://github.com/tryphotino/photino.NET/issues/95) | | +| ❓ | The application lacks a taskbar Icon | [Photino.NET#106](https://github.com/tryphotino/photino.NET/issues/106) | | +| ❓ | Possibility to hide the window from the taskbar/dock | [Photino.NET#107](https://github.com/tryphotino/photino.NET/issues/107) | | +| ❓ | Make a window only miniable | [Photino.NET#112](https://github.com/tryphotino/photino.NET/issues/112) | | +| ❓ | Interoperability between C # and JavaScript | [Photino.NET#120](https://github.com/tryphotino/photino.NET/issues/120) | | +| ✅ | Window.external is deprecated by browsers | [Photino.NET#124](https://github.com/tryphotino/photino.NET/issues/124) | | +| ❓ | Intercept navigation events | [Photino.NET#139](https://github.com/tryphotino/photino.NET/issues/139) | | +| ❓ | ShowSaveFile How to set the default file name | [Photino.NET#140](https://github.com/tryphotino/photino.NET/issues/140) | | +| ❓ | Running as administrator disables other instances | [Photino.NET#162](https://github.com/tryphotino/photino.NET/issues/162) | | +| ❌ | Mica/Fluent design window for windows | [Photino.NET#167](https://github.com/tryphotino/photino.NET/issues/167) | | +| ❓ | Retrieve current Url of PhotinoWindow | [Photino.NET#197](https://github.com/tryphotino/photino.NET/issues/197) | | +| ✅ | Ctrl+ mouse wheel to zoom the page | [Photino.NET#217](https://github.com/tryphotino/photino.NET/issues/217) | | +| ❓ | RegisterCustomSchemeHandler won't let do fetch and XMLHttpRequest requests | [Photino.NET#232](https://github.com/tryphotino/photino.NET/issues/232) | | +| ❓ | Set the background color of the native window body | [Photino.NET#239](https://github.com/tryphotino/photino.NET/issues/239) | | +| ✅ | Default csproj file uses WinExe but should use Exe so it will properly build on all platforms | [Photino.NET#240](https://github.com/tryphotino/photino.NET/issues/240) | | +| ❓ | Disable browser shortcuts | [Photino.NET#251](https://github.com/tryphotino/photino.NET/issues/251) | | +| 🚧 | Fixed WebView2 runtime | [Photino.NET#254](https://github.com/tryphotino/photino.NET/issues/254) | [InfiniFrame#275](https://github.com/InfiniLore/InfiniFrame/issues/275) | +| ✅ | `ILogger` implementation | [Photino.NET#257](https://github.com/tryphotino/photino.NET/issues/257) | | +| ❓ | Add complex notifications | [Photino.NET#261](https://github.com/tryphotino/photino.NET/pull/261) | | +| ❓ | Import StartDragging, StartResizing | [Photino.NET#262](https://github.com/tryphotino/photino.NET/pull/262) | | +| ❓ | Bindings for controlling taskbar progress/flash | [Photino.NET#263](https://github.com/tryphotino/photino.NET/pull/263) | | +| ❓ | Allow parent window to be set before initialized | [Photino.NET#264](https://github.com/tryphotino/photino.NET/pull/264) | | +| ❓ | Window header size | [Photino.NET#266](https://github.com/tryphotino/photino.NET/issues/266) | | +| ❓ | Optional window title 31-char-length-limitation on Linux/GTK | [Photino.NET#267](https://github.com/tryphotino/photino.NET/pull/267) | | +| ❓ | Inject Arbitrary Javascript into Webview | [Photino.NET#268](https://github.com/tryphotino/photino.NET/issues/268) | | +| ❓ | Parent vs child window behavior | [Photino.NET#269](https://github.com/tryphotino/photino.NET/issues/269) | | +| 🚧 | WindowClosed event | [Photino.NET#271](https://github.com/tryphotino/photino.NET/issues/271) | [InfiniFrame#277](https://github.com/InfiniLore/InfiniFrame/issues/277) | +| ✅ | SetIconFile Linux crash | [Photino.NET#272](https://github.com/tryphotino/photino.NET/issues/272) | [InfiniFrame#165](https://github.com/InfiniLore/InfiniFrame/pull/165) | --- @@ -107,4 +108,4 @@ This backlog will be used to track any remaining issues or features that need to | Status | Feature | Links | InfiniFrame PR | |:-------|:----------------------------|:----------------------------------------------------------------------------------|:---------------| -| ✅ | Photino Development Server | [Photino.NET.Server#4](https://github.com/tryphotino/photino.NET.Server/issues/4) | | \ No newline at end of file +| ✅ | Photino Development Server | [Photino.NET.Server#4](https://github.com/tryphotino/photino.NET.Server/issues/4) | | diff --git a/scripts/BuildFrontend.mjs b/scripts/BuildFrontend.mjs new file mode 100644 index 000000000..3c67660e1 --- /dev/null +++ b/scripts/BuildFrontend.mjs @@ -0,0 +1,150 @@ +import {spawnSync} from 'node:child_process'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; + +const [, , appDirectoryArg, stampFileArg, outputFileArg] = process.argv; + +if (!appDirectoryArg || !stampFileArg || !outputFileArg) { + console.error('Usage: node BuildFrontend.mjs '); + process.exit(1); +} + +const appDirectory = path.resolve(appDirectoryArg); +const stampFile = path.resolve(stampFileArg); +const outputFile = path.resolve(outputFileArg); +const lockDirectory = `${stampFile}.lock`; +const sourceExclusions = new Set(['node_modules', '.git']); + +function sleep(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function isProcessRunning(processId) { + if (!Number.isInteger(processId) || processId <= 0) { + return false; + } + + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +} + +function shouldRemoveExistingLock() { + const ownerFile = path.join(lockDirectory, 'owner.txt'); + + if (existsSync(ownerFile)) { + const ownerProcessId = Number.parseInt(readFileSync(ownerFile, 'utf8'), 10); + if (!isProcessRunning(ownerProcessId)) { + return true; + } + } + + const lockAgeMilliseconds = Date.now() - statSync(lockDirectory).mtimeMs; + return lockAgeMilliseconds > 60 * 1000; +} + +function acquireLock() { + mkdirSync(path.dirname(stampFile), {recursive: true}); + + const startedAt = Date.now(); + while (true) { + try { + mkdirSync(lockDirectory); + writeFileSync(path.join(lockDirectory, 'owner.txt'), `${process.pid}\n`, 'utf8'); + return; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + + if (shouldRemoveExistingLock()) { + rmSync(lockDirectory, {recursive: true, force: true}); + continue; + } + + if (Date.now() - startedAt > 2 * 60 * 1000) { + throw new Error(`Timed out waiting for frontend build lock: ${lockDirectory}`); + } + + sleep(250); + } + } +} + +function getLatestSourceWriteTime(directory) { + let latest = statSync(directory).mtimeMs; + + for (const entry of readdirSync(directory, {withFileTypes: true})) { + if (sourceExclusions.has(entry.name)) { + continue; + } + + const entryPath = path.join(directory, entry.name); + const entryStats = statSync(entryPath); + latest = Math.max(latest, entryStats.mtimeMs); + + if (entry.isDirectory()) { + latest = Math.max(latest, getLatestSourceWriteTime(entryPath)); + } + } + + return latest; +} + +function isBuildCurrent() { + if (!existsSync(stampFile) || !existsSync(outputFile)) { + return false; + } + + return statSync(stampFile).mtimeMs >= getLatestSourceWriteTime(appDirectory); +} + +function runNpm(args) { + const npmCommand = process.platform === 'win32' ? 'cmd.exe' : 'npm'; + const npmArgs = process.platform === 'win32' + ? ['/d', '/s', '/c', 'npm', ...args] + : args; + + const result = spawnSync(npmCommand, npmArgs, { + cwd: appDirectory, + stdio: 'inherit', + }); + + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +acquireLock(); + +try { + if (isBuildCurrent()) { + console.log('Frontend build output is current.'); + process.exit(0); + } + + if (process.env.CI === 'true') { + runNpm(['ci']); + } + + runNpm(['run', 'build']); + writeFileSync(stampFile, `${new Date().toISOString()}\n`, 'utf8'); +} finally { + rmSync(lockDirectory, {recursive: true, force: true}); +} diff --git a/scripts/clion-linux-environment.sh b/scripts/clion-linux-environment.sh index e18653c1d..20f3bba93 100644 --- a/scripts/clion-linux-environment.sh +++ b/scripts/clion-linux-environment.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/bash set -e echo "Updating package lists..." diff --git a/scripts/docker-linux-compose.sh b/scripts/docker-linux-compose.sh index c2c914178..661ae737b 100644 --- a/scripts/docker-linux-compose.sh +++ b/scripts/docker-linux-compose.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/src/InfiniFrame.Native/Core/InfiniFrameWindow.h b/src/InfiniFrame.Native/Core/InfiniFrameWindow.h index ddfe70914..8b364da73 100644 --- a/src/InfiniFrame.Native/Core/InfiniFrameWindow.h +++ b/src/InfiniFrame.Native/Core/InfiniFrameWindow.h @@ -78,6 +78,11 @@ class InfiniFrameWindow { /** @brief Close the window and terminate the message loop */ void Close(); +#ifdef __linux__ + /** @brief Request close from a GTK delete-event handler */ + [[nodiscard]] bool RequestClose(); +#endif + // ----------------------------------------------------------------------------------------------------------------- // Get Properties // ----------------------------------------------------------------------------------------------------------------- diff --git a/src/InfiniFrame.Native/Platform/Linux/Window.cpp b/src/InfiniFrame.Native/Platform/Linux/Window.cpp index 7de503c1b..b06d02011 100644 --- a/src/InfiniFrame.Native/Platform/Linux/Window.cpp +++ b/src/InfiniFrame.Native/Platform/Linux/Window.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include "Embedded/Embedded.h" std::mutex invokeLockMutex; +std::once_flag platformInitOnce; struct InvokeWaitInfo { ACTION callback; @@ -51,6 +53,7 @@ struct InfiniFrameWindow::Impl : InfiniFrameWindowImpl { std::string _temporaryFilesPath; + bool _closed = false; bool _isFullScreen = false; double _zoom = 100.0; int _minWidth = 0; @@ -186,6 +189,46 @@ static std::string escapeJsonString(std::string_view input) { return result; } +static GtkWidget* create_web_view( + WebKitUserContentManager* contentManager, + const std::string& temporaryFilesPath + ) { + if (temporaryFilesPath.empty()) + return webkit_web_view_new_with_user_content_manager(contentManager); + + std::string dataDirectory = temporaryFilesPath + "/data"; + std::string cacheDirectory = temporaryFilesPath + "/cache"; + + g_mkdir_with_parents(dataDirectory.c_str(), 0700); + g_mkdir_with_parents(cacheDirectory.c_str(), 0700); + + WebKitWebsiteDataManager* dataManager = webkit_website_data_manager_new( + "base-data-directory", dataDirectory.c_str(), + "base-cache-directory", cacheDirectory.c_str(), + NULL + ); + WebKitWebContext* context = webkit_web_context_new_with_website_data_manager(dataManager); + GtkWidget* webView = GTK_WIDGET(g_object_new( + WEBKIT_TYPE_WEB_VIEW, + "web-context", context, + "user-content-manager", contentManager, + NULL + )); + + g_object_unref(context); + g_object_unref(dataManager); + + return webView; +} + +static void ensure_platform_initialized() { + std::call_once(platformInitOnce, [] { + XInitThreads(); + gtk_init(nullptr, nullptr); + notify_init("InfiniFrame"); + }); +} + // --------------------------------------------------------------------------------------------------------------------- // Impl method definitions // --------------------------------------------------------------------------------------------------------------------- @@ -299,7 +342,9 @@ void InfiniFrameWindow::Impl::AddCustomSchemeHandlers() { if (_customSchemeCallback == nullptr) return; - WebKitWebContext* context = webkit_web_context_get_default(); + WebKitWebContext* context = _webview != nullptr + ? webkit_web_view_get_context(WEBKIT_WEB_VIEW(_webview)) + : webkit_web_context_get_default(); WebKitSecurityManager* securityManager = webkit_web_context_get_security_manager(context); for (const auto& value : _customSchemeNames) { if (securityManager != nullptr && g_ascii_strcasecmp(value.c_str(), "app") == 0) { @@ -323,9 +368,7 @@ void InfiniFrameWindow::Impl::AddCustomSchemeHandlers() { InfiniFrameWindow::InfiniFrameWindow(InfiniFrameInitParams* initParams) : m_impl(std::make_unique()) { - XInitThreads(); - gtk_init(nullptr, nullptr); - notify_init(initParams->Title); + ensure_platform_initialized(); if (initParams->Size != sizeof(InfiniFrameInitParams)) { GtkWidget* dialog = gtk_message_dialog_new( @@ -494,8 +537,10 @@ InfiniFrameWindow::InfiniFrameWindow(InfiniFrameInitParams* initParams) : } InfiniFrameWindow::~InfiniFrameWindow() { - notify_uninit(); - gtk_widget_destroy(m_impl->_window); + if (!m_impl->_closed && m_impl->_window != nullptr) { + m_impl->_closed = true; + gtk_widget_destroy(m_impl->_window); + } } // --------------------------------------------------------------------------------------------------------------------- @@ -547,7 +592,26 @@ void InfiniFrameWindow::ClearBrowserAutoFill() { } void InfiniFrameWindow::Close() { - gtk_window_close(GTK_WINDOW(m_impl->_window)); + RequestClose(); +} + +bool InfiniFrameWindow::RequestClose() { + if (m_impl->_closed) + return true; + + if (InvokeClose()) + return false; + + m_impl->_closed = true; + + if (m_impl->_window != nullptr) { + gtk_widget_destroy(m_impl->_window); + } + + if (gtk_main_level() > 0) + gtk_main_quit(); + + return true; } // --------------------------------------------------------------------------------------------------------------------- @@ -861,11 +925,15 @@ void InfiniFrameWindow::ShowNotification(const AutoString title, const AutoStrin } void InfiniFrameWindow::WaitForExit() { + if (m_impl->_closed) + return; + g_signal_connect( G_OBJECT(m_impl->_window), "destroy", G_CALLBACK( +[](GtkWidget*, gpointer) { - gtk_main_quit(); + if (gtk_main_level() > 0) + gtk_main_quit(); } ), nullptr @@ -1002,7 +1070,8 @@ void InfiniFrameWindow::Show(bool isAlreadyShown) { struct sigaction old_action; sigaction(SIGCHLD, nullptr, &old_action); WebKitUserContentManager* contentManager = webkit_user_content_manager_new(); - m_impl->_webview = webkit_web_view_new_with_user_content_manager(contentManager); + m_impl->_webview = create_web_view(contentManager, m_impl->_temporaryFilesPath); + g_object_unref(contentManager); m_impl->set_webkit_settings(); @@ -1101,7 +1170,8 @@ gboolean on_window_state_event(GtkWidget* widget, GdkEventWindowState* event, co gboolean on_widget_deleted(GtkWidget* widget, GdkEvent* event, const gpointer self) { auto* instance = reinterpret_cast(self); - return instance->InvokeClose(); + instance->RequestClose(); + return TRUE; } gboolean on_focus_in_event(GtkWidget* widget, GdkEvent* event, const gpointer self) { diff --git a/src/InfiniFrame.Native/Platform/Mac/Window.mm b/src/InfiniFrame.Native/Platform/Mac/Window.mm index 018b6b4ae..ef7c31e87 100644 --- a/src/InfiniFrame.Native/Platform/Mac/Window.mm +++ b/src/InfiniFrame.Native/Platform/Mac/Window.mm @@ -290,6 +290,13 @@ m_impl->_webviewConfiguration = [[WKWebViewConfiguration alloc] init]; + if (!m_impl->_temporaryFilesPath.empty()) + { + // WKWebView does not expose a public API for choosing an arbitrary website data directory. + // Use an isolated temporary store instead of silently falling back to the shared persistent store. + m_impl->_webviewConfiguration.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + } + for (const auto & scheme : m_impl->_customSchemeNames) { // Note: diff --git a/src/InfiniFrame/InfiniFrameWindowNativeParameterBuilder.cs b/src/InfiniFrame/InfiniFrameWindowNativeParameterBuilder.cs index abfe97217..03a338b1b 100644 --- a/src/InfiniFrame/InfiniFrameWindowNativeParameterBuilder.cs +++ b/src/InfiniFrame/InfiniFrameWindowNativeParameterBuilder.cs @@ -38,7 +38,7 @@ public class InfiniFrameWindowNativeParameterBuilder : IInfiniFrameWindowNativeP public bool SmoothScrollingEnabled { get; set; } = true; public string? StartString { get; set; } public string? StartUrl { get; set; } - public string? TemporaryFilesPath { get; set; } = Path.Join(Path.GetTempPath(), "infiniframe"); + public string? TemporaryFilesPath { get; set; } = Path.Join(Path.GetTempPath(), "infiniframe", Environment.ProcessId.ToString()); public string Title { get; set; } = "InfiniFrame"; public int Top { get; set; } public bool TopMost { get; set; } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index d94004f21..1ed37d8c0 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 + false 14.0 enable diff --git a/tests/InfiniFrameTests.Playwright.WebApp.React/InfiniFrameTests.Playwright.WebApp.React.csproj b/tests/InfiniFrameTests.Playwright.WebApp.React/InfiniFrameTests.Playwright.WebApp.React.csproj index 0bb9001db..152bf2ab1 100644 --- a/tests/InfiniFrameTests.Playwright.WebApp.React/InfiniFrameTests.Playwright.WebApp.React.csproj +++ b/tests/InfiniFrameTests.Playwright.WebApp.React/InfiniFrameTests.Playwright.WebApp.React.csproj @@ -26,19 +26,15 @@ - + - - - + @@ -69,4 +65,4 @@ SkipUnchangedFiles="true" /> - \ No newline at end of file + diff --git a/tests/InfiniFrameTests.Playwright.WebApp.Vue/InfiniFrameTests.Playwright.WebApp.Vue.csproj b/tests/InfiniFrameTests.Playwright.WebApp.Vue/InfiniFrameTests.Playwright.WebApp.Vue.csproj index 3719630a0..de01ab4d5 100644 --- a/tests/InfiniFrameTests.Playwright.WebApp.Vue/InfiniFrameTests.Playwright.WebApp.Vue.csproj +++ b/tests/InfiniFrameTests.Playwright.WebApp.Vue/InfiniFrameTests.Playwright.WebApp.Vue.csproj @@ -26,19 +26,15 @@ - + - - - + @@ -69,4 +65,4 @@ SkipUnchangedFiles="true" /> - \ No newline at end of file + diff --git a/tests/InfiniFrameTests.Shared/InfiniFrameWindowTestUtility.cs b/tests/InfiniFrameTests.Shared/InfiniFrameWindowTestUtility.cs index 973f20a00..b33760c83 100644 --- a/tests/InfiniFrameTests.Shared/InfiniFrameWindowTestUtility.cs +++ b/tests/InfiniFrameTests.Shared/InfiniFrameWindowTestUtility.cs @@ -45,38 +45,73 @@ public static InfiniFrameWindowTestUtility Create( var windowBuilder = InfiniFrameWindowBuilder.Create(); windowBuilder.SetStartString(StartString); + if (OperatingSystem.IsLinux()) { + windowBuilder.SetTemporaryFilesPath(Path.Join( + Path.GetTempPath(), + "infiniframe-tests", + Environment.ProcessId.ToString(), + Guid.NewGuid().ToString("N"))); + } + builder?.Invoke(windowBuilder); - // Windows: WebView2 requires STA thread for COM initialization - // Linux: GTK implicitly treats the calling thread as the main UI thread - // macOS: Similar to Linux, but with additional main-thread restrictions for menu operations - if (OperatingSystem.IsWindows()) { - return CreateOnStaThread(windowBuilder); - } - else { - // On Linux/macOS, create the window in the current thread to ensure proper GTK initialization - IInfiniFrameWindow window = windowBuilder.Build(); - - var utility = new InfiniFrameWindowTestUtility { - Window = window - }; - - var thread = new Thread(() => { - try { - window.WaitForClose(); - } - catch (ApplicationException) { - // Ignore shutdown exceptions during test cleanup - } - }) { - IsBackground = true - }; - - utility._windowThread = thread; - thread.Start(); - - return utility; - } + // Windows: WebView2 requires STA thread for COM initialization. + // Linux: GTK's message loop must run on the same thread that created the window. + // macOS: Create the window on the current thread because Cocoa has main-thread restrictions. + if (OperatingSystem.IsWindows()) return CreateOnStaThread(windowBuilder); + if (OperatingSystem.IsLinux()) return CreateOnDedicatedThread(windowBuilder); + + IInfiniFrameWindow window = windowBuilder.Build(); + + var utility = new InfiniFrameWindowTestUtility { + Window = window + }; + + var thread = new Thread(() => { + try { + window.WaitForClose(); + } + catch (ApplicationException) { + // Ignore shutdown exceptions during test cleanup + } + }) { + IsBackground = true + }; + + utility._windowThread = thread; + thread.Start(); + + return utility; + } + + [MustDisposeResource] + private static InfiniFrameWindowTestUtility CreateOnDedicatedThread( + InfiniFrameWindowBuilder windowBuilder + ) { + var windowSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => { + try { + IInfiniFrameWindow window = windowBuilder.Build(); + windowSource.SetResult(window); + window.WaitForClose(); + } + catch (Exception ex) when (IsNonFatalException(ex)) { + windowSource.TrySetException(ex); + } + }) { + IsBackground = true, + Name = "InfiniFrame Test Window Thread" + }; + + thread.Start(); + + var utility = new InfiniFrameWindowTestUtility { + Window = windowSource.Task.GetAwaiter().GetResult(), + _windowThread = thread + }; + + return utility; } [SupportedOSPlatform("windows")] diff --git a/tests/InfiniFrameTests/MultiWindowTests.cs b/tests/InfiniFrameTests/MultiWindowTests.cs new file mode 100644 index 000000000..37b798224 --- /dev/null +++ b/tests/InfiniFrameTests/MultiWindowTests.cs @@ -0,0 +1,37 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrameTests.Shared; + +namespace InfiniFrameTests; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class MultiWindowTests { + [Test] + [SkipUtility.SkipOnMacOs] + [NotInParallel(ParallelControl.InfiniFrame)] + [Timeout(TimeoutUtility.DefaultTimeout)] + public async Task OpenWindowAfterOneCloses(CancellationToken ct) { + // Arrange + int closingRequestedCounter = 0; + var window1Utility = InfiniFrameWindowTestUtility.Create( + builder => builder.RegisterWindowClosingRequestedHandler(_ => { + Interlocked.Increment(ref closingRequestedCounter); + }), ct); + + // Act + window1Utility.Dispose(); + + var window2Utility = InfiniFrameWindowTestUtility.Create( + builder => builder.RegisterWindowClosingRequestedHandler(_ => { + Interlocked.Increment(ref closingRequestedCounter); + }), ct); + + window2Utility.Dispose(); + + // Assert + await Assert.That(closingRequestedCounter).IsEqualTo(2); + } +}