Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ jobs:
## Vivaldi++ ${{ github.ref_name }}

- Native portable mode, startup arguments and boss key for Vivaldi and Chrome.
- Portable relaunch now uses CreateProcessW with explicit paths, safe quoting and no inherited handles.
- Optional lifecycle actions (`lifecycle_actions=0` by default), configurable in the WebUI or INI.
- First real instance runs startup; last normal exit runs exit. Multi-instance and relaunch ordering are coordinated in memory.
- Exit actions wait at most three seconds and are not guaranteed on crashes or forced termination.
- Optional embedded WebUI with Chinese/English, light/dark/system themes and the updated logo.
- WebUI is **off by default**. Missing `webui` in existing configurations is treated as `0`.
- To enable it, set `[general] webui=1` in `config.ini` and fully restart every browser instance.
Expand All @@ -106,6 +110,8 @@ jobs:

### 中文说明
支持 Vivaldi 和 Chrome,分别提供安装包。网页设置默认关闭;旧配置未写 `webui` 时同样按 `0` 处理。
新增默认关闭的启停动作:在 WebUI 或 `[general] lifecycle_actions=1` 中启用并配置两条命令。
同一安装首次启动/最后正常退出执行,重复窗口不重复触发;退出最多等待三秒,崩溃或强杀不保证执行。
在 `config.ini` 的 `[general]` 中设置 `webui=1`,完全退出所有浏览器实例后重新启动,即可从关于页进入。
网页、Logo 和服务均嵌入 DLL,入口由 C++ 处理,不释放安装脚本或生成备份。
Chrome 安装包提供本项目的便携化、启动参数和老板键功能,不迁移其他 Chrome++ 项目的标签增强配置。
Expand Down
16 changes: 14 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: '22'
Expand All @@ -26,13 +28,23 @@ jobs:
run: |
New-Item -ItemType Directory -Path ./build/test-tools -Force | Out-Null
./tests/run-native.ps1
./tests/run-portable-launch.ps1 -BinaryOutput (Join-Path $pwd 'build/test-tools/portable_launch_tests.exe')
./tests/run-web-http.ps1
./tests/run-web-config.ps1
./tests/run-about-patch.ps1
./tests/run-gzip.ps1 -BinaryOutput (Join-Path $pwd 'build/test-tools/gzip_tests.exe')
./tests/run-chrome-about.ps1 -BinaryOutput (Join-Path $pwd 'build/test-tools/chrome_about_tests.exe')
- name: Web and about-page regression tests
run: node --test tests/*.test.mjs web/tests/*.test.cjs
- name: Build and exercise lifecycle actions
shell: pwsh
run: |
./tests/build-lifecycle-host.ps1
node --test tests/lifecycle.test.mjs
- uses: actions/upload-artifact@v4
with:
name: verified-lifecycle-host
path: build/lifecycle-host/*.exe
- name: Web configuration regression tests
run: node --test web/tests/*.test.cjs
- name: Build the embedded settings test host
shell: pwsh
run: ./tests/build-web-host.ps1
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@

### 自定义配置

#### 启停动作(默认关闭)

启停动作与 WebUI 开关独立。未配置 `lifecycle_actions` 时按 `0` 处理,不执行任何命令。
同一安装目录下,第一个真正持有用户数据目录的实例执行启动动作,最后一个正常退出的实例执行退出动作。
打开第二个窗口、转交网址的临时进程不会重复执行;多个独立配置实例也共用这一组动作。

```ini
[general]
lifecycle_actions=1
launch_on_startup='"%app%\tools\helper.exe" --portable'
launch_on_exit=cmd.exe /d /c "%app%\tools\cleanup.cmd"
```

每项填写一条 Windows 可执行程序命令行,支持 `%app%`、环境变量和带引号的路径。
手动编辑 INI 时,含双引号的完整命令可用外层单引号包住,避免 Windows INI 读取时去掉程序路径的引号。
不自动调用命令解释器,不按分号拆分;运行脚本时需明确写出 `cmd.exe /d /c` 或 `powershell.exe -File`。
控制台命令在后台执行,图形程序仍可显示自己的窗口。
退出动作最多等待 3 秒,超时后浏览器继续退出,不终止已启动的命令;崩溃或强制结束不保证执行退出动作。
重启时先等待旧实例的退出动作,再执行新实例的启动动作。修改后完全退出所有实例再启动。

开启 WebUI 后,也可在“启停动作”中设置开关和两条命令。WebUI 不提供立即运行命令的按钮。

#### 网页设置(默认关闭)

网页设置为可选功能。没有 `config.ini`,或旧配置没有 `webui`,都按 `webui=0` 处理:
Expand Down Expand Up @@ -315,6 +337,22 @@ xmake

### Configuration

#### Lifecycle actions (off by default)

`[general] lifecycle_actions=0` is the default, including existing configurations with no such key.
Set it to `1` and configure `launch_on_startup` / `launch_on_exit`, or use the WebUI's Lifecycle actions section.
Within one installation, the first real browser owner runs startup and the last normal exit runs the exit command.
Forwarding processes and additional windows do not execute another pair. Separate user-data directories share this installation-wide group.

Each field is one executable command line. `%app%`, environment variables and quoted paths are supported.
For manual INI edits, enclose a command containing double quotes in outer single quotes, for example
`launch_on_startup='"%app%\tools\helper.exe" --portable'`.
No implicit shell or semicolon splitting is used; specify `cmd.exe /d /c` or `powershell.exe -File` for scripts.
Console commands run without a console window; GUI applications may show their own windows.
The browser waits at most three seconds for an exit command and does not kill it afterward.
Exit actions are not guaranteed after a crash or forced termination. On relaunch, the previous exit wait precedes the new startup command.
Completely exit every browser instance before applying changed configuration.

#### Web settings (off by default)

Missing configuration or a missing `[general] webui` key is treated as `webui=0`.
Expand Down
13 changes: 13 additions & 0 deletions config.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@
; Disabling removes only our own entry and preserves other browser resources
webui=0

; Optional lifecycle actions. Missing or 0 keeps all actions disabled.
; First real instance starts the startup command; last normal exit starts the exit command.
; Each value is ONE Windows executable command line; use an explicit interpreter for scripts.
; %app% and environment variables are expanded. Quote paths containing spaces.
; For manual INI edits, wrap a command containing double quotes in outer single quotes:
; launch_on_startup='"%app%\tools\helper.exe" --portable'
; launch_on_exit=cmd.exe /d /c "%app%\tools\cleanup.cmd"
; Exit commands are waited on for up to 3 seconds, then left running.
; Crash/forced termination cannot guarantee exit commands. Fully restart all instances after edits.
lifecycle_actions=0
launch_on_startup=
launch_on_exit=

; Win32k System Call Policy
; Controls whether to forcibly enable win32k system calls in renderer processes
;
Expand Down
13 changes: 13 additions & 0 deletions config.ini.example.zh-CN
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@
; 关闭时只移除本项目自己的入口,保留浏览器其他资源
webui=0

; 启停动作默认关闭;旧配置缺少此开关同样按 0 处理。
; 同一安装目录:第一个有效实例执行启动动作,最后一个正常退出的实例执行退出动作。
; 每项是一条 Windows 可执行程序命令行;脚本必须显式指定解释器,不自动拆分分号。
; 支持 %app% 和环境变量,带空格的路径需要双引号。
; 手动编辑 INI 时,含双引号的完整命令可再用外层单引号包住:
; launch_on_startup='"%app%\tools\helper.exe" --portable'
; launch_on_exit=cmd.exe /d /c "%app%\tools\cleanup.cmd"
; 退出动作最多等待 3 秒,超时后不强杀命令。崩溃/强杀不保证执行退出动作。
; 修改后完全退出所有浏览器实例再启动。
lifecycle_actions=0
launch_on_startup=
launch_on_exit=

; Win32k 系统调用策略
; 控制是否强制启用渲染进程中的 win32k 系统调用
;
Expand Down
12 changes: 12 additions & 0 deletions docs/startup-lifecycle-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Startup reliability and lifecycle actions

Implement the approved features independently from the current MIT codebase; do not copy upstream GPL implementation.

1. Replace portable relaunch via ShellExecuteEx with CreateProcessW. Preserve exact parsed arguments, quoting and explicit profile/cache precedence; set the child working directory to the browser executable directory, disable handle inheritance and close returned handles.
2. Add `[general] lifecycle_actions=0`, `launch_on_startup=` and `launch_on_exit=`. Missing keys keep the feature off. Each action is one Windows executable command line, with quoted paths, `%app%` and environment variables. Shell scripts require an explicit interpreter such as `cmd.exe /d /c` or `powershell.exe -File`.
3. Register real browser owners only after their Chromium message-only singleton window appears. Coordinate by installation in memory: first valid instance runs startup, final normal exit runs exit. Forwarding processes do not execute actions. Serialize restart so the old exit action's bounded wait precedes the new startup action. Wait at most three seconds for an exit command; do not kill the command. Crashes/forced termination do not guarantee exit actions.
4. Add a concise WebUI section with a default-off switch and startup/exit command inputs. Retain Chinese/English, themes, logo, navigation selection, INI comment preservation and opt-in WebUI behavior.
5. Verify native launch round trips, default-off behavior, single/multiple owners, URL forwarding, restart ordering, timeout, dead-process cleanup, command quoting and UI save/reload. Use isolated Chrome and Vivaldi profiles, never real user data or destructive example commands.
6. Run three-architecture Actions builds and independent reviews. Merge only passing changes, release a new stable version with six browser/architecture assets, and delete this task branch afterward.

No new dependencies, installer scripts, persistent coordination files or direct upstream code copies.
13 changes: 13 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class Config
bool win32k_enabled_;
bool debug_log_enabled_;
bool webui_enabled_ = false;
bool lifecycle_actions_enabled_ = false;
std::wstring launch_on_startup_;
std::wstring launch_on_exit_;
std::wstring command_line_;
std::wstring disable_features_ = L"WinSboxNoFakeGdiInit,WebUIInProcessResourceLoading";
bool has_custom_disable_features_;
Expand All @@ -41,6 +44,12 @@ class Config
}

webui_enabled_ = (GetPrivateProfileIntW(L"general", L"webui", 0, config_path_.c_str()) != 0);
lifecycle_actions_enabled_ = (GetPrivateProfileIntW(L"general", L"lifecycle_actions", 0, config_path_.c_str()) == 1);
wchar_t action_buffer[8192];
GetPrivateProfileStringW(L"general", L"launch_on_startup", L"", action_buffer, 8192, config_path_.c_str());
launch_on_startup_ = action_buffer;
GetPrivateProfileStringW(L"general", L"launch_on_exit", L"", action_buffer, 8192, config_path_.c_str());
launch_on_exit_ = action_buffer;

// Read win32k setting from [general] section
// 0 = disabled (default, safer, better for video streaming)
Expand Down Expand Up @@ -103,6 +112,10 @@ class Config
return webui_enabled_;
}

bool AreLifecycleActionsEnabled() const { return lifecycle_actions_enabled_; }
const std::wstring& GetLaunchOnStartup() const { return launch_on_startup_; }
const std::wstring& GetLaunchOnExit() const { return launch_on_exit_; }

// Returns additional command line arguments from config
const std::wstring& GetCommandLine() const
{
Expand Down
171 changes: 171 additions & 0 deletions src/lifecycle.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#include "lifecycle.h"
#include "lifecycle_commands.h"
#include "lifecycle_owners.h"
#ifdef VIVALDI_PLUS_LIFECYCLE_TEST
void LifecycleTestBeforeJoin();
void LifecycleTestAfterJoin();
#endif
#include <windows.h>
#include <detours.h>
#include <atomic>
#include <cstdint>
#include <cwchar>
#include <memory>
#include <new>
#include <vector>

std::wstring GetAppDir();
namespace {
constexpr DWORD kLockTimeout = 4500;


struct Context {
LifecycleOptions options;
std::wstring appDir;
HANDLE mutex = nullptr;
HANDLE mapping = nullptr;
LifecycleSharedState* shared = nullptr;
LifecycleParticipant self{};
std::atomic<bool> joining{false};
std::atomic<bool> exiting{false};
};
std::atomic<bool> initialized{false};
std::atomic<Context*> context{nullptr};
auto originalExitProcess = &ExitProcess;

bool Lock(HANDLE mutex) {
DWORD status = WaitForSingleObject(mutex, kLockTimeout);
if (status == WAIT_OBJECT_0 || status == WAIT_ABANDONED) return true;
OutputDebugStringW(L"[vivaldi++] Lifecycle mutex wait failed or timed out.\n");
return false;
}
std::wstring ObjectName(const std::wstring& directory) {
uint64_t hash = 14695981039346656037ULL;
std::wstring lowercase = directory;
CharLowerBuffW(lowercase.data(), static_cast<DWORD>(lowercase.size()));
for (wchar_t ch : lowercase) {
hash ^= static_cast<uint16_t>(ch);
hash *= 1099511628211ULL;
}
wchar_t name[80]{};
swprintf_s(name, L"Local\\VivaldiPlusLifecycle-v1-%016llx", static_cast<unsigned long long>(hash));
return name;
}
void CloseState(Context& state) {
if (state.shared) UnmapViewOfFile(state.shared);
if (state.mapping) CloseHandle(state.mapping);
if (state.mutex) CloseHandle(state.mutex);
state.shared = nullptr;
state.mapping = state.mutex = nullptr;
}
bool OpenState(Context& state) {
const auto name = ObjectName(state.appDir);
state.mutex = CreateMutexW(nullptr, FALSE, (name + L"-mutex").c_str());
if (!state.mutex) {
OutputDebugStringW(L"[vivaldi++] Lifecycle mutex creation failed.\n");
return false;
}
if (!Lock(state.mutex)) { CloseState(state); return false; }
state.mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0,
sizeof(LifecycleSharedState), (name + L"-state").c_str());
if (state.mapping) state.shared = static_cast<LifecycleSharedState*>(MapViewOfFile(state.mapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(LifecycleSharedState)));
if (!state.shared) OutputDebugStringW(L"[vivaldi++] Lifecycle shared state creation failed.\n");
ReleaseMutex(state.mutex);
if (!state.shared) { CloseState(state); return false; }
return true;
}
void Join(Context& state) {
if (!Lock(state.mutex)) return;
if (!state.exiting.load()) {
const size_t count = CleanLifecycleOwners(*state.shared);
if (AddLifecycleOwner(*state.shared, state.self)) {
if (count == 0) RunLifecycleCommand(state.options.startup, state.appDir, false);
}
}
ReleaseMutex(state.mutex);
}
DWORD WINAPI WaitForOwner(void* parameter) {
auto& state = *static_cast<Context*>(parameter);
while (!state.exiting.load()) {
if (OwnsLifecycleProfileWindow()) {
#ifdef VIVALDI_PLUS_LIFECYCLE_TEST
LifecycleTestBeforeJoin();
#endif
state.joining.store(true);
if (!state.exiting.load()) Join(state);
state.joining.store(false);
#ifdef VIVALDI_PLUS_LIFECYCLE_TEST
LifecycleTestAfterJoin();
#endif
return 0;
}
Sleep(100);
}
return 0;
}
void WINAPI LifecycleExitProcess(UINT code) {
Context* state = context.load();
if (state && !state->exiting.exchange(true)) {
const ULONGLONG deadline = GetTickCount64() + kLockTimeout;
while (state->joining.load() && GetTickCount64() < deadline) Sleep(1);
if (state->joining.load() || !state->shared || !Lock(state->mutex)) {
originalExitProcess(code);
return;
}
const bool registered = HasLifecycleOwner(*state->shared, state->self);
for (auto& owner : state->shared->owners) {
if (owner.pid == state->self.pid && CompareFileTime(&owner.created, &state->self.created) == 0) owner = {};
}
CleanLifecycleOwners(*state->shared);
if (registered) AdoptLifecycleWindowOwners(*state->shared, state->appDir, state->self.pid);
if (registered && CleanLifecycleOwners(*state->shared) == 0) {
RunLifecycleCommand(state->options.exit, state->appDir, true);
}
ReleaseMutex(state->mutex);
}
originalExitProcess(code);
}
} // namespace

void InitializeLifecycle(const LifecycleOptions& options) {
if (!options.enabled || (options.startup.empty() && options.exit.empty())) return;
if (initialized.exchange(true)) return;
std::unique_ptr<Context> owned(new (std::nothrow) Context);
if (!owned) {
OutputDebugStringW(L"[vivaldi++] Lifecycle context allocation failed.\n");
return;
}
auto* state = owned.get();
state->options = options;
state->appDir = GetAppDir();
state->self.pid = GetCurrentProcessId();
if (state->appDir.empty() || !LifecycleCreationTime(GetCurrentProcess(), state->self.created)) {
OutputDebugStringW(L"[vivaldi++] Lifecycle process identification failed.\n");
return;
}
if (DetourTransactionBegin() != NO_ERROR) {
context.store(nullptr);
OutputDebugStringW(L"[vivaldi++] Lifecycle hook transaction could not begin.\n");
return;
}
LONG result = DetourUpdateThread(GetCurrentThread());
if (result == NO_ERROR) result = DetourAttach(reinterpret_cast<PVOID*>(&originalExitProcess), reinterpret_cast<PVOID>(LifecycleExitProcess));
if (result != NO_ERROR) {
DetourTransactionAbort();
context.store(nullptr);
OutputDebugStringW(L"[vivaldi++] Lifecycle exit hook attachment failed.\n");
return;
}
if (DetourTransactionCommit() != NO_ERROR) {
context.store(nullptr);
OutputDebugStringW(L"[vivaldi++] Lifecycle exit hook transaction failed.\n");
return;
}
if (!OpenState(*state)) return;
owned.release();
context.store(state);
// The process owns this context until ExitProcess; no DLL-detach waits.
HANDLE thread = CreateThread(nullptr, 0, WaitForOwner, state, 0, nullptr);
if (thread) CloseHandle(thread);
else OutputDebugStringW(L"[vivaldi++] Lifecycle owner thread creation failed.\n");
}
8 changes: 8 additions & 0 deletions src/lifecycle.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#pragma once
#include <string>
struct LifecycleOptions {
bool enabled = false;
std::wstring startup;
std::wstring exit;
};
void InitializeLifecycle(const LifecycleOptions& options);
Loading
Loading