diff --git a/.gitignore b/.gitignore index c9a1414..eb55ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ electron-dist/ Pair-linux-source.zip .DS_Store nul +*.log +build*.log +app_ids.txt +html_ids.txt +addon/build/* +!addon/build/Release/ +addon/build/Release/* +!addon/build/Release/pair-capture.node diff --git a/addon/build/Release/obj/pair-capture/pair-capture.node.recipe b/addon/build/Release/obj/pair-capture/pair-capture.node.recipe deleted file mode 100644 index 1da9fe3..0000000 --- a/addon/build/Release/obj/pair-capture/pair-capture.node.recipe +++ /dev/null @@ -1,11 +0,0 @@ - - - - - C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.node - - - - - - \ No newline at end of file diff --git a/addon/build/Release/obj/pair-capture/pair-capture.obj b/addon/build/Release/obj/pair-capture/pair-capture.obj deleted file mode 100644 index 8b9dee8..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.obj and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog deleted file mode 100644 index 6b17fcb..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.command.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog deleted file mode 100644 index 44df7e6..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.read.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog deleted file mode 100644 index a2929a6..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/CL.write.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog deleted file mode 100644 index af7e258..0000000 --- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/Cl.items.tlog +++ /dev/null @@ -1,2 +0,0 @@ -C:\Users\benja\Documents\discordult\addon\pair-capture.cc;C:\Users\benja\Documents\discordult\addon\build\Release\obj\pair-capture\pair-capture.obj -C:\Users\benja\AppData\Roaming\npm\node_modules\node-gyp\src\win_delay_load_hook.cc;C:\Users\benja\Documents\discordult\addon\build\Release\obj\pair-capture\win_delay_load_hook.obj diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog deleted file mode 100644 index 53021a0..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.command.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog deleted file mode 100644 index 51effdb..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.read.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog deleted file mode 100644 index a7925f3..0000000 --- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.secondary.1.tlog +++ /dev/null @@ -1,5 +0,0 @@ -^C:\USERS\BENJA\DOCUMENTS\DISCORDULT\ADDON\BUILD\RELEASE\OBJ\PAIR-CAPTURE\PAIR-CAPTURE.OBJ|C:\USERS\BENJA\DOCUMENTS\DISCORDULT\ADDON\BUILD\RELEASE\OBJ\PAIR-CAPTURE\WIN_DELAY_LOAD_HOOK.OBJ -C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.LIB -C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.EXP -C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.IPDB -C:\Users\benja\Documents\discordult\addon\build\Release\pair-capture.IOBJ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog b/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog deleted file mode 100644 index 441a3af..0000000 Binary files a/addon/build/Release/obj/pair-capture/pair-capture.tlog/link.write.1.tlog and /dev/null differ diff --git a/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate b/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate deleted file mode 100644 index 595281b..0000000 --- a/addon/build/Release/obj/pair-capture/pair-capture.tlog/pair-capture.lastbuildstate +++ /dev/null @@ -1,2 +0,0 @@ -PlatformToolSet=v145:VCToolArchitecture=Native64Bit:VCToolsVersion=14.51.36231:VCServicingVersionCrtHeaders=14.51.36244:VCServicingVersionCompilers=14.51.36244:TargetPlatformVersion=10.0.26100.0: -Release|x64|C:\Users\benja\Documents\discordult\addon\build\| diff --git a/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj b/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj deleted file mode 100644 index ce4d2a7..0000000 Binary files a/addon/build/Release/obj/pair-capture/win_delay_load_hook.obj and /dev/null differ diff --git a/addon/build/Release/pair-capture.exp b/addon/build/Release/pair-capture.exp deleted file mode 100644 index 6e5ec02..0000000 Binary files a/addon/build/Release/pair-capture.exp and /dev/null differ diff --git a/addon/build/Release/pair-capture.iobj b/addon/build/Release/pair-capture.iobj deleted file mode 100644 index 4046f1b..0000000 Binary files a/addon/build/Release/pair-capture.iobj and /dev/null differ diff --git a/addon/build/Release/pair-capture.ipdb b/addon/build/Release/pair-capture.ipdb deleted file mode 100644 index 8ff3681..0000000 Binary files a/addon/build/Release/pair-capture.ipdb and /dev/null differ diff --git a/addon/build/Release/pair-capture.lib b/addon/build/Release/pair-capture.lib deleted file mode 100644 index 09b7ecc..0000000 Binary files a/addon/build/Release/pair-capture.lib and /dev/null differ diff --git a/addon/build/Release/pair-capture.node b/addon/build/Release/pair-capture.node index c0052b5..b1a6024 100644 Binary files a/addon/build/Release/pair-capture.node and b/addon/build/Release/pair-capture.node differ diff --git a/addon/build/Release/pair-capture.pdb b/addon/build/Release/pair-capture.pdb deleted file mode 100644 index 44fd82f..0000000 Binary files a/addon/build/Release/pair-capture.pdb and /dev/null differ diff --git a/addon/build/binding.sln b/addon/build/binding.sln deleted file mode 100644 index 4871138..0000000 --- a/addon/build/binding.sln +++ /dev/null @@ -1,19 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 2015 -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "pair-capture", "pair-capture.vcxproj", "{139377FD-1DA0-C185-31F2-879258A3A79A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Release|x64 = Release|x64 - Debug|x64 = Debug|x64 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {139377FD-1DA0-C185-31F2-879258A3A79A}.Release|x64.ActiveCfg = Release|x64 - {139377FD-1DA0-C185-31F2-879258A3A79A}.Release|x64.Build.0 = Release|x64 - {139377FD-1DA0-C185-31F2-879258A3A79A}.Debug|x64.ActiveCfg = Debug|x64 - {139377FD-1DA0-C185-31F2-879258A3A79A}.Debug|x64.Build.0 = Debug|x64 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/addon/build/config.gypi b/addon/build/config.gypi deleted file mode 100644 index 3c0ee31..0000000 --- a/addon/build/config.gypi +++ /dev/null @@ -1,517 +0,0 @@ -# Do not edit. File was generated by node-gyp's "configure" step -{ - "target_defaults": { - "cflags": [], - "configurations": { - "Debug": { - "v8_enable_v8_checks": 0, - "variables": {} - }, - "Release": { - "v8_enable_v8_checks": 1, - "variables": {} - } - }, - "default_configuration": "Release", - "defines": [], - "include_dirs": [], - "libraries": [], - "msbuild_toolset": "v170", - "msvs_windows_target_platform_version": "10.0.26100.0" - }, - "variables": { - "arm_fpu": "neon", - "asan": 0, - "build_v8_with_gn": "false", - "built_with_electron": 1, - "control_flow_guard": "false", - "coverage": "false", - "dcheck_always_on": 0, - "debug_nghttp2": "false", - "debug_node": "false", - "enable_lto": "false", - "enable_pgo_generate": "false", - "enable_pgo_use": "false", - "error_on_warn": "false", - "force_dynamic_crt": 0, - "host_arch": "x64", - "icu_data_in": "..\\..\\deps\\icu-tmp\\icudt77l.dat", - "icu_endianness": "l", - "icu_gyp_path": "tools/icu/icu-generic.gyp", - "icu_path": "deps/icu-small", - "icu_small": "false", - "icu_ver_major": "77", - "libdir": "lib", - "llvm_version": "0.0", - "napi_build_version": "10", - "node_builtin_shareable_builtins": [ - "deps/cjs-module-lexer/lexer.js", - "deps/cjs-module-lexer/dist/lexer.js", - "deps/undici/undici.js", - "deps/amaro/dist/index.js" - ], - "node_byteorder": "little", - "node_cctest_sources": [ - "src/node_snapshot_stub.cc", - "test/cctest/inspector/test_node_protocol.cc", - "test/cctest/node_test_fixture.cc", - "test/cctest/test_aliased_buffer.cc", - "test/cctest/test_base64.cc", - "test/cctest/test_base_object_ptr.cc", - "test/cctest/test_cppgc.cc", - "test/cctest/test_crypto_clienthello.cc", - "test/cctest/test_dataqueue.cc", - "test/cctest/test_environment.cc", - "test/cctest/test_inspector_socket.cc", - "test/cctest/test_inspector_socket_server.cc", - "test/cctest/test_json_utils.cc", - "test/cctest/test_linked_binding.cc", - "test/cctest/test_node_api.cc", - "test/cctest/test_node_crypto.cc", - "test/cctest/test_node_crypto_env.cc", - "test/cctest/test_node_postmortem_metadata.cc", - "test/cctest/test_node_task_runner.cc", - "test/cctest/test_path.cc", - "test/cctest/test_per_process.cc", - "test/cctest/test_platform.cc", - "test/cctest/test_quic_cid.cc", - "test/cctest/test_quic_error.cc", - "test/cctest/test_quic_tokens.cc", - "test/cctest/test_report.cc", - "test/cctest/test_sockaddr.cc", - "test/cctest/test_traced_value.cc", - "test/cctest/test_util.cc", - "test/cctest/node_test_fixture.h" - ], - "node_debug_lib": "false", - "node_enable_d8": "false", - "node_enable_v8_vtunejit": "false", - "node_enable_v8windbg": "false", - "node_fipsinstall": "false", - "node_install_corepack": "true", - "node_install_npm": "true", - "node_library_files": [ - "lib/_http_agent.js", - "lib/_http_client.js", - "lib/_http_common.js", - "lib/_http_incoming.js", - "lib/_http_outgoing.js", - "lib/_http_server.js", - "lib/_stream_duplex.js", - "lib/_stream_passthrough.js", - "lib/_stream_readable.js", - "lib/_stream_transform.js", - "lib/_stream_wrap.js", - "lib/_stream_writable.js", - "lib/_tls_common.js", - "lib/_tls_wrap.js", - "lib/assert.js", - "lib/assert/strict.js", - "lib/async_hooks.js", - "lib/buffer.js", - "lib/child_process.js", - "lib/cluster.js", - "lib/console.js", - "lib/constants.js", - "lib/crypto.js", - "lib/dgram.js", - "lib/diagnostics_channel.js", - "lib/dns.js", - "lib/dns/promises.js", - "lib/domain.js", - "lib/events.js", - "lib/fs.js", - "lib/fs/promises.js", - "lib/http.js", - "lib/http2.js", - "lib/https.js", - "lib/inspector.js", - "lib/inspector/promises.js", - "lib/internal/abort_controller.js", - "lib/internal/assert.js", - "lib/internal/assert/assertion_error.js", - "lib/internal/assert/calltracker.js", - "lib/internal/assert/myers_diff.js", - "lib/internal/assert/utils.js", - "lib/internal/async_context_frame.js", - "lib/internal/async_hooks.js", - "lib/internal/async_local_storage/async_context_frame.js", - "lib/internal/async_local_storage/async_hooks.js", - "lib/internal/blob.js", - "lib/internal/blocklist.js", - "lib/internal/bootstrap/node.js", - "lib/internal/bootstrap/realm.js", - "lib/internal/bootstrap/shadow_realm.js", - "lib/internal/bootstrap/switches/does_not_own_process_state.js", - "lib/internal/bootstrap/switches/does_own_process_state.js", - "lib/internal/bootstrap/switches/is_main_thread.js", - "lib/internal/bootstrap/switches/is_not_main_thread.js", - "lib/internal/bootstrap/web/exposed-wildcard.js", - "lib/internal/bootstrap/web/exposed-window-or-worker.js", - "lib/internal/buffer.js", - "lib/internal/child_process.js", - "lib/internal/child_process/serialization.js", - "lib/internal/cli_table.js", - "lib/internal/cluster/child.js", - "lib/internal/cluster/primary.js", - "lib/internal/cluster/round_robin_handle.js", - "lib/internal/cluster/shared_handle.js", - "lib/internal/cluster/utils.js", - "lib/internal/cluster/worker.js", - "lib/internal/console/constructor.js", - "lib/internal/console/global.js", - "lib/internal/constants.js", - "lib/internal/crypto/aes.js", - "lib/internal/crypto/certificate.js", - "lib/internal/crypto/cfrg.js", - "lib/internal/crypto/cipher.js", - "lib/internal/crypto/diffiehellman.js", - "lib/internal/crypto/ec.js", - "lib/internal/crypto/hash.js", - "lib/internal/crypto/hashnames.js", - "lib/internal/crypto/hkdf.js", - "lib/internal/crypto/keygen.js", - "lib/internal/crypto/keys.js", - "lib/internal/crypto/mac.js", - "lib/internal/crypto/pbkdf2.js", - "lib/internal/crypto/random.js", - "lib/internal/crypto/rsa.js", - "lib/internal/crypto/scrypt.js", - "lib/internal/crypto/sig.js", - "lib/internal/crypto/util.js", - "lib/internal/crypto/webcrypto.js", - "lib/internal/crypto/webidl.js", - "lib/internal/crypto/x509.js", - "lib/internal/data_url.js", - "lib/internal/debugger/inspect.js", - "lib/internal/debugger/inspect_client.js", - "lib/internal/debugger/inspect_repl.js", - "lib/internal/dgram.js", - "lib/internal/dns/callback_resolver.js", - "lib/internal/dns/promises.js", - "lib/internal/dns/utils.js", - "lib/internal/encoding.js", - "lib/internal/error_serdes.js", - "lib/internal/errors.js", - "lib/internal/errors/error_source.js", - "lib/internal/event_target.js", - "lib/internal/events/abort_listener.js", - "lib/internal/events/symbols.js", - "lib/internal/file.js", - "lib/internal/fixed_queue.js", - "lib/internal/freelist.js", - "lib/internal/freeze_intrinsics.js", - "lib/internal/fs/cp/cp-sync.js", - "lib/internal/fs/cp/cp.js", - "lib/internal/fs/dir.js", - "lib/internal/fs/glob.js", - "lib/internal/fs/promises.js", - "lib/internal/fs/read/context.js", - "lib/internal/fs/recursive_watch.js", - "lib/internal/fs/rimraf.js", - "lib/internal/fs/streams.js", - "lib/internal/fs/sync_write_stream.js", - "lib/internal/fs/utils.js", - "lib/internal/fs/watchers.js", - "lib/internal/heap_utils.js", - "lib/internal/histogram.js", - "lib/internal/http.js", - "lib/internal/http2/compat.js", - "lib/internal/http2/core.js", - "lib/internal/http2/util.js", - "lib/internal/inspector/network.js", - "lib/internal/inspector/network_http.js", - "lib/internal/inspector/network_http2.js", - "lib/internal/inspector/network_resources.js", - "lib/internal/inspector/network_undici.js", - "lib/internal/inspector_async_hook.js", - "lib/internal/inspector_network_tracking.js", - "lib/internal/js_stream_socket.js", - "lib/internal/legacy/processbinding.js", - "lib/internal/linkedlist.js", - "lib/internal/main/check_syntax.js", - "lib/internal/main/embedding.js", - "lib/internal/main/eval_stdin.js", - "lib/internal/main/eval_string.js", - "lib/internal/main/inspect.js", - "lib/internal/main/mksnapshot.js", - "lib/internal/main/print_help.js", - "lib/internal/main/prof_process.js", - "lib/internal/main/repl.js", - "lib/internal/main/run_main_module.js", - "lib/internal/main/test_runner.js", - "lib/internal/main/watch_mode.js", - "lib/internal/main/worker_thread.js", - "lib/internal/mime.js", - "lib/internal/modules/cjs/loader.js", - "lib/internal/modules/customization_hooks.js", - "lib/internal/modules/esm/assert.js", - "lib/internal/modules/esm/create_dynamic_module.js", - "lib/internal/modules/esm/formats.js", - "lib/internal/modules/esm/get_format.js", - "lib/internal/modules/esm/hooks.js", - "lib/internal/modules/esm/initialize_import_meta.js", - "lib/internal/modules/esm/load.js", - "lib/internal/modules/esm/loader.js", - "lib/internal/modules/esm/module_job.js", - "lib/internal/modules/esm/module_map.js", - "lib/internal/modules/esm/resolve.js", - "lib/internal/modules/esm/shared_constants.js", - "lib/internal/modules/esm/translators.js", - "lib/internal/modules/esm/utils.js", - "lib/internal/modules/esm/worker.js", - "lib/internal/modules/helpers.js", - "lib/internal/modules/package_json_reader.js", - "lib/internal/modules/run_main.js", - "lib/internal/modules/typescript.js", - "lib/internal/navigator.js", - "lib/internal/net.js", - "lib/internal/options.js", - "lib/internal/per_context/domexception.js", - "lib/internal/per_context/messageport.js", - "lib/internal/per_context/primordials.js", - "lib/internal/perf/event_loop_delay.js", - "lib/internal/perf/event_loop_utilization.js", - "lib/internal/perf/nodetiming.js", - "lib/internal/perf/observe.js", - "lib/internal/perf/performance.js", - "lib/internal/perf/performance_entry.js", - "lib/internal/perf/resource_timing.js", - "lib/internal/perf/timerify.js", - "lib/internal/perf/usertiming.js", - "lib/internal/perf/utils.js", - "lib/internal/priority_queue.js", - "lib/internal/process/execution.js", - "lib/internal/process/finalization.js", - "lib/internal/process/per_thread.js", - "lib/internal/process/permission.js", - "lib/internal/process/pre_execution.js", - "lib/internal/process/promises.js", - "lib/internal/process/report.js", - "lib/internal/process/signal.js", - "lib/internal/process/task_queues.js", - "lib/internal/process/warning.js", - "lib/internal/process/worker_thread_only.js", - "lib/internal/promise_hooks.js", - "lib/internal/querystring.js", - "lib/internal/quic/quic.js", - "lib/internal/quic/state.js", - "lib/internal/quic/stats.js", - "lib/internal/quic/symbols.js", - "lib/internal/readline/callbacks.js", - "lib/internal/readline/emitKeypressEvents.js", - "lib/internal/readline/interface.js", - "lib/internal/readline/promises.js", - "lib/internal/readline/utils.js", - "lib/internal/repl.js", - "lib/internal/repl/await.js", - "lib/internal/repl/history.js", - "lib/internal/repl/utils.js", - "lib/internal/socket_list.js", - "lib/internal/socketaddress.js", - "lib/internal/source_map/prepare_stack_trace.js", - "lib/internal/source_map/source_map.js", - "lib/internal/source_map/source_map_cache.js", - "lib/internal/source_map/source_map_cache_map.js", - "lib/internal/stream_base_commons.js", - "lib/internal/streams/add-abort-signal.js", - "lib/internal/streams/compose.js", - "lib/internal/streams/destroy.js", - "lib/internal/streams/duplex.js", - "lib/internal/streams/duplexify.js", - "lib/internal/streams/duplexpair.js", - "lib/internal/streams/end-of-stream.js", - "lib/internal/streams/from.js", - "lib/internal/streams/lazy_transform.js", - "lib/internal/streams/legacy.js", - "lib/internal/streams/operators.js", - "lib/internal/streams/passthrough.js", - "lib/internal/streams/pipeline.js", - "lib/internal/streams/readable.js", - "lib/internal/streams/state.js", - "lib/internal/streams/transform.js", - "lib/internal/streams/utils.js", - "lib/internal/streams/writable.js", - "lib/internal/test/binding.js", - "lib/internal/test/transfer.js", - "lib/internal/test_runner/assert.js", - "lib/internal/test_runner/coverage.js", - "lib/internal/test_runner/harness.js", - "lib/internal/test_runner/mock/loader.js", - "lib/internal/test_runner/mock/mock.js", - "lib/internal/test_runner/mock/mock_timers.js", - "lib/internal/test_runner/reporter/dot.js", - "lib/internal/test_runner/reporter/junit.js", - "lib/internal/test_runner/reporter/lcov.js", - "lib/internal/test_runner/reporter/spec.js", - "lib/internal/test_runner/reporter/tap.js", - "lib/internal/test_runner/reporter/utils.js", - "lib/internal/test_runner/reporter/v8-serializer.js", - "lib/internal/test_runner/runner.js", - "lib/internal/test_runner/snapshot.js", - "lib/internal/test_runner/test.js", - "lib/internal/test_runner/tests_stream.js", - "lib/internal/test_runner/utils.js", - "lib/internal/timers.js", - "lib/internal/tls/secure-context.js", - "lib/internal/tls/secure-pair.js", - "lib/internal/trace_events_async_hooks.js", - "lib/internal/tty.js", - "lib/internal/url.js", - "lib/internal/util.js", - "lib/internal/util/colors.js", - "lib/internal/util/comparisons.js", - "lib/internal/util/debuglog.js", - "lib/internal/util/diff.js", - "lib/internal/util/inspect.js", - "lib/internal/util/inspector.js", - "lib/internal/util/parse_args/parse_args.js", - "lib/internal/util/parse_args/utils.js", - "lib/internal/util/trace_sigint.js", - "lib/internal/util/types.js", - "lib/internal/v8/startup_snapshot.js", - "lib/internal/v8_prof_polyfill.js", - "lib/internal/v8_prof_processor.js", - "lib/internal/validators.js", - "lib/internal/vm.js", - "lib/internal/vm/module.js", - "lib/internal/wasm_web_api.js", - "lib/internal/watch_mode/files_watcher.js", - "lib/internal/watchdog.js", - "lib/internal/webidl.js", - "lib/internal/webstorage.js", - "lib/internal/webstreams/adapters.js", - "lib/internal/webstreams/compression.js", - "lib/internal/webstreams/encoding.js", - "lib/internal/webstreams/queuingstrategies.js", - "lib/internal/webstreams/readablestream.js", - "lib/internal/webstreams/transfer.js", - "lib/internal/webstreams/transformstream.js", - "lib/internal/webstreams/util.js", - "lib/internal/webstreams/writablestream.js", - "lib/internal/worker.js", - "lib/internal/worker/clone_dom_exception.js", - "lib/internal/worker/io.js", - "lib/internal/worker/js_transferable.js", - "lib/internal/worker/messaging.js", - "lib/module.js", - "lib/net.js", - "lib/os.js", - "lib/path.js", - "lib/path/posix.js", - "lib/path/win32.js", - "lib/perf_hooks.js", - "lib/process.js", - "lib/punycode.js", - "lib/querystring.js", - "lib/readline.js", - "lib/readline/promises.js", - "lib/repl.js", - "lib/sea.js", - "lib/sqlite.js", - "lib/stream.js", - "lib/stream/consumers.js", - "lib/stream/promises.js", - "lib/stream/web.js", - "lib/string_decoder.js", - "lib/sys.js", - "lib/test.js", - "lib/test/reporters.js", - "lib/timers.js", - "lib/timers/promises.js", - "lib/tls.js", - "lib/trace_events.js", - "lib/tty.js", - "lib/url.js", - "lib/util.js", - "lib/util/types.js", - "lib/v8.js", - "lib/vm.js", - "lib/wasi.js", - "lib/worker_threads.js", - "lib/zlib.js" - ], - "node_module_version": 136, - "node_no_browser_globals": "false", - "node_prefix": "\\usr\\local", - "node_release_urlbase": "", - "node_shared": "false", - "node_shared_ada": "false", - "node_shared_brotli": "false", - "node_shared_cares": "false", - "node_shared_http_parser": "false", - "node_shared_libuv": "false", - "node_shared_nghttp2": "false", - "node_shared_nghttp3": "false", - "node_shared_ngtcp2": "false", - "node_shared_openssl": "false", - "node_shared_simdjson": "false", - "node_shared_simdutf": "false", - "node_shared_sqlite": "false", - "node_shared_uvwasi": "false", - "node_shared_zlib": "false", - "node_shared_zstd": "false", - "node_tag": "", - "node_target_type": "executable", - "node_use_amaro": "true", - "node_use_bundled_v8": "true", - "node_use_node_code_cache": "false", - "node_use_node_snapshot": "false", - "node_use_openssl": "true", - "node_use_sqlite": "true", - "node_use_v8_platform": "true", - "node_with_ltcg": "true", - "node_without_node_options": "false", - "node_write_snapshot_as_array_literals": "true", - "openssl_is_fips": "false", - "openssl_no_asm": 1, - "openssl_quic": "false", - "ossfuzz": "false", - "shlib_suffix": "so.127", - "single_executable_application": "true", - "suppress_all_error_on_warn": "false", - "target_arch": "x64", - "ubsan": 0, - "use_ccache_win": 0, - "use_prefix_to_find_headers": "false", - "using_electron_config_gypi": 1, - "v8_enable_31bit_smis_on_64bit_arch": 1, - "v8_enable_extensible_ro_snapshot": 0, - "v8_enable_external_code_space": 1, - "v8_enable_gdbjit": 0, - "v8_enable_hugepage": 0, - "v8_enable_i18n_support": 1, - "v8_enable_inspector": 1, - "v8_enable_javascript_promise_hooks": 1, - "v8_enable_lite_mode": 0, - "v8_enable_maglev": 0, - "v8_enable_object_print": 1, - "v8_enable_pointer_compression": 1, - "v8_enable_pointer_compression_shared_cage": 1, - "v8_enable_sandbox": 1, - "v8_enable_shared_ro_heap": 0, - "v8_enable_webassembly": 1, - "v8_optimized_debug": 1, - "v8_promise_internal_field_count": 1, - "v8_random_seed": 0, - "v8_trace_maps": 0, - "v8_use_siphash": 1, - "want_separate_host_toolset": 1, - "nodedir": "C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3", - "python": "C:\\Python314\\python.exe", - "standalone_static_library": 1, - "msbuild_path": "C:\\Program Files (x86)\\Microsoft Visual Studio\\18\\BuildTools\\MSBuild\\Current\\Bin\\MSBuild.exe", - "runtime": "electron", - "target": "37.10.3", - "user_agent": "npm/11.13.0 node/v24.16.0 win32 x64 workspaces/false", - "userconfig": "C:\\Users\\benja\\.npmrc", - "prefix": "C:\\Users\\benja\\AppData\\Roaming\\npm", - "npm_version": "11.13.0", - "node_gyp": "C:\\Program Files\\nodejs\\node_modules\\npm\\node_modules\\node-gyp\\bin\\node-gyp.js", - "local_prefix": "C:\\Users\\benja\\Documents\\discordult\\addon", - "init_module": "C:\\Users\\benja\\.npm-init.js", - "global_prefix": "C:\\Users\\benja\\AppData\\Roaming\\npm", - "globalconfig": "C:\\Users\\benja\\AppData\\Roaming\\npm\\etc\\npmrc", - "cache": "C:\\Users\\benja\\AppData\\Local\\npm-cache" - } -} diff --git a/addon/build/pair-capture.vcxproj b/addon/build/pair-capture.vcxproj deleted file mode 100644 index ffd0e1a..0000000 --- a/addon/build/pair-capture.vcxproj +++ /dev/null @@ -1,148 +0,0 @@ - - - - - Debug - x64 - - - Release - x64 - - - - {139377FD-1DA0-C185-31F2-879258A3A79A} - Win32Proj - pair-capture - true - x64 - 10.0.26100.0 - - - - DynamicLibrary - - - v170 - - - - - - - - - - $(ExecutablePath);$(MSBuildProjectDirectory)\..\bin\;$(MSBuildProjectDirectory)\..\bin\ - true - $(Configuration)\obj\$(ProjectName)\ - false - true - $(SolutionDir)$(Configuration)\ - .node - .node - .node - .node - $(ProjectName) - $(OutDir)\$(ProjectName).node - - - - C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories) - /Zc:__cplusplus -std:c++20 /Zm2000 %(AdditionalOptions) - EnableFastChecks - true - OldStyle - 4351;4355;4800;4251;4275;4244;4267;%(DisableSpecificWarnings) - Sync - false - true - false - Disabled - NotUsing - NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";DEBUG;_DEBUG;%(PreprocessorDefinitions) - MultiThreadedDebug - true - true - false - Level3 - true - - - /LTCG:INCREMENTAL %(AdditionalOptions) - - - kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;uuid.lib;odbc32.lib;delayimp.lib;"C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3\\x64\\node.lib";ole32.lib;oleaut32.lib - /LTCG:INCREMENTAL /ignore:4199 %(AdditionalOptions) - node.exe;%(DelayLoadDLLs) - true - true - true - $(OutDir)$(ProjectName).node - true - .node - MachineX64 - - - C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories) - NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";DEBUG;_DEBUG;%(PreprocessorDefinitions);%(PreprocessorDefinitions) - - - - - C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories) - /Zc:__cplusplus -std:c++20 /Zm2000 %(AdditionalOptions) - true - OldStyle - 4351;4355;4800;4251;4275;4244;4267;%(DisableSpecificWarnings) - Sync - Speed - true - AnySuitable - true - true - true - Full - NotUsing - NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";%(PreprocessorDefinitions) - MultiThreaded - false - true - true - false - Level3 - true - - - /LTCG:INCREMENTAL %(AdditionalOptions) - - - kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;uuid.lib;odbc32.lib;delayimp.lib;"C:\\Users\\benja\\AppData\\Local\\node-gyp\\Cache\\37.10.3\\x64\\node.lib";ole32.lib;oleaut32.lib - /LTCG:INCREMENTAL /ignore:4199 %(AdditionalOptions) - node.exe;%(DelayLoadDLLs) - true - true - true - $(OutDir)$(ProjectName).node - true - .node - MachineX64 - - - C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\include\node;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\src;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\config;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\openssl\openssl\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\uv\include;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\zlib;C:\Users\benja\AppData\Local\node-gyp\Cache\37.10.3\deps\v8\include;..\..\node_modules\node-addon-api;%(AdditionalIncludeDirectories) - NODE_GYP_MODULE_NAME=pair-capture;USING_UV_SHARED=1;USING_V8_SHARED=1;V8_DEPRECATION_WARNINGS=1;_GLIBCXX_USE_CXX11_ABI=1;_FILE_OFFSET_BITS=64;ELECTRON_ENSURE_CONFIG_GYPI;USING_ELECTRON_CONFIG_GYPI;V8_COMPRESS_POINTERS;V8_COMPRESS_POINTERS_IN_SHARED_CAGE;V8_31BIT_SMIS_ON_64BIT_ARCH;V8_ENABLE_SANDBOX;V8_EXTERNAL_CODE_SPACE;WIN32;_CRT_SECURE_NO_DEPRECATE;_CRT_NONSTDC_NO_DEPRECATE;_HAS_EXCEPTIONS=0;NOMINMAX;OPENSSL_NO_PINSHARED;OPENSSL_THREADS;OPENSSL_NO_ASM;BUILDING_NODE_EXTENSION;HOST_BINARY="node.exe";%(PreprocessorDefinitions);%(PreprocessorDefinitions) - - - - - - - - $(IntDir)\pair-capture.obj - - - - - - - diff --git a/addon/build/pair-capture.vcxproj.filters b/addon/build/pair-capture.vcxproj.filters deleted file mode 100644 index 0f239b0..0000000 --- a/addon/build/pair-capture.vcxproj.filters +++ /dev/null @@ -1,46 +0,0 @@ - - - - - {739DB09A-CC57-A953-A6CF-F64FA08E4FA7} - - - {7B735499-E5DD-1C2B-6C26-70023832A1CF} - - - {E9F714C1-DA89-54E2-60CF-39FEB20BF756} - - - {7F0EA4FD-B064-B82B-EA7E-4436B0159B62} - - - {87566BA0-CA33-1144-65F5-087C5F9D6C20} - - - {D7E4E273-4F57-58B8-AAC7-5AC8BF4576BB} - - - {56DF7A98-063D-FB9D-485C-089023B4C16A} - - - {77348C0E-2034-7791-74D5-63C077DF5A3B} - - - {8CDEE807-BC53-E450-C8B8-4DEBB66742D4} - - - {739DB09A-CC57-A953-A6CF-F64FA08E4FA7} - - - - - .. - - - C:\Users\benja\Documents\discordult\node_modules\node-gyp\src - - - .. - - - diff --git a/addon/pair-capture.cc b/addon/pair-capture.cc index b2680da..b3d289c 100644 --- a/addon/pair-capture.cc +++ b/addon/pair-capture.cc @@ -5,18 +5,18 @@ #include #include #include -#include -#include #include #include -#include #include #include #include +#include -// Windows process loopback lets us capture the system mix while excluding -// Pair's process tree. This is the same class of capture Discord uses to keep -// its own voice playback out of a stream. It needs Windows 10 build 20348+. +// Windows process loopback captures the system mix while excluding Pair's +// process tree, so call playback is not shared with the screen. This needs +// Windows 10 build 20348+. Older Windows is not given an endpoint-loopback +// fallback: that mix can still contain Pair playback (call voice and remote +// screen audio), so the app shares video only when process exclusion fails. struct ActivationState { HANDLE event=nullptr; HRESULT result=E_FAIL; @@ -51,16 +51,7 @@ class ActivationHandler final : public IActivateAudioInterfaceCompletionHandler, class Capture { public: - std::vector refRing; - std::mutex refMutex; - uint64_t refWritten=0; - float estimatedGain=0.5f; - int bestDelay=2048; bool runningFlag=false; - static constexpr int RING_SIZE=96000; - - IMMDeviceEnumerator* enumerator=nullptr; - IMMDevice* device=nullptr; IAudioClient* audioClient=nullptr; IAudioCaptureClient* captureClient=nullptr; WAVEFORMATEX* mixFormat=nullptr; @@ -70,7 +61,7 @@ class Capture { std::thread captureThread; Napi::ThreadSafeFunction dataCb,errCb; - Capture():refRing(RING_SIZE,0.0f){} + Capture()=default; ~Capture(){stop();cleanup();} void start(Napi::Function dataCbFn,Napi::Function errCbFn){ @@ -97,13 +88,8 @@ class Capture { if(errCb){errCb.Release();errCb=nullptr;} } - void pushRef(float* data,size_t frames){ - std::lock_guard lk(refMutex); - for(size_t i=0;inChannels)); o.Set("bitsPerSample",Napi::Number::New(env,(double)mixFormat->wBitsPerSample)); o.Set("sampleType",mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT?Napi::String::New(env,"float"):Napi::String::New(env,"pcm")); + o.Set("mode",Napi::String::New(env,"process")); + o.Set("available",Napi::Boolean::New(env,true)); return o; } private: - HRESULT initWasapi(){ - HRESULT hr=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); - if(SUCCEEDED(hr))comInitialized=true; - else if(hr!=RPC_E_CHANGED_MODE)return hr; + HRESULT finishClientInit(){ + HRESULT hr=audioClient->GetBufferSize(&bufFrames); + if(FAILED(hr))return hr; + captureEvent=CreateEvent(nullptr,FALSE,FALSE,nullptr); + if(!captureEvent)return E_FAIL; + hr=audioClient->SetEventHandle(captureEvent); + if(FAILED(hr))return hr; + return audioClient->GetService(__uuidof(IAudioCaptureClient),(void**)&captureClient); + } - // Capture every Windows render stream except Pair and its child processes. - // Unlike endpoint loopback, this does not need an audio-device selection - // and therefore cannot feed the remote caller's Pair audio back to them. + HRESULT initProcessLoopback(){ ActivationState state; state.event=CreateEvent(nullptr,FALSE,FALSE,nullptr); if(!state.event)return HRESULT_FROM_WIN32(GetLastError()); @@ -134,13 +125,10 @@ class Capture { PROPVARIANT prop={}; prop.vt=VT_BLOB;prop.blob.cbSize=sizeof(params);prop.blob.pBlobData=(BYTE*)¶ms; auto* handler=new ActivationHandler(&state); - // Resolve dynamically: older SDK link libraries do not always export this - // newer API even though supported Windows releases do. That lets one addon - // run on both current and older Windows without a loader failure. HMODULE audioApi=LoadLibraryW(L"mmdevapi.dll"); auto activate=audioApi?reinterpret_cast(GetProcAddress(audioApi,"ActivateAudioInterfaceAsync")):nullptr; IActivateAudioInterfaceAsyncOperation* operation=nullptr; - hr=activate?activate(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,__uuidof(IAudioClient),&prop,handler,&operation):HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND); + HRESULT hr=activate?activate(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,__uuidof(IAudioClient),&prop,handler,&operation):HRESULT_FROM_WIN32(ERROR_PROC_NOT_FOUND); if(FAILED(hr)){if(audioApi)FreeLibrary(audioApi);handler->Release();CloseHandle(state.event);return hr;} WaitForSingleObject(state.event,INFINITE); if(operation)operation->Release(); @@ -148,9 +136,6 @@ class Capture { handler->Release();CloseHandle(state.event); if(FAILED(state.result))return state.result; audioClient=state.client; - - // Request a predictable PCM format. Windows converts the process mix for - // us, which keeps the Node bridge's real-time samples simple and stable. auto* requested=(WAVEFORMATEX*)CoTaskMemAlloc(sizeof(WAVEFORMATEX)); if(!requested)return E_OUTOFMEMORY; ZeroMemory(requested,sizeof(WAVEFORMATEX)); @@ -159,18 +144,19 @@ class Capture { mixFormat=requested; hr=audioClient->Initialize(AUDCLNT_SHAREMODE_SHARED,AUDCLNT_STREAMFLAGS_LOOPBACK|AUDCLNT_STREAMFLAGS_EVENTCALLBACK|AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,0,0,mixFormat,nullptr); if(FAILED(hr))return hr; - hr=audioClient->GetBufferSize(&bufFrames); - if(FAILED(hr))return hr; - captureEvent=CreateEvent(nullptr,FALSE,FALSE,nullptr); - if(!captureEvent)return E_FAIL; - hr=audioClient->SetEventHandle(captureEvent); - if(FAILED(hr))return hr; - hr=audioClient->GetService(__uuidof(IAudioCaptureClient),(void**)&captureClient); - return hr; + return finishClientInit(); + } + + HRESULT initWasapi(){ + HRESULT hr=CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); + if(SUCCEEDED(hr))comInitialized=true; + else if(hr!=RPC_E_CHANGED_MODE)return hr; + + // Process exclusion only. Endpoint loopback is intentionally unavailable: + // it cannot guarantee Pair playback stays out of the share mix. + return initProcessLoopback(); } - int diagCounter=0; - int totalProcessed=0; void loop(){ HRESULT hr=audioClient->Start(); if(FAILED(hr)){emitErr("start failed");return;} @@ -190,76 +176,31 @@ class Capture { audioClient->Stop(); } - void updateDelay(float* captured,int frames,int sr){ - if(refWritten<(uint64_t)(sr*0.05))return; - static int counter=0; - if(++counter%30!=0)return; - - int maxD=std::min((int)(sr*0.15),RING_SIZE-frames-256); - int minD=(int)(sr*0.01); - if(minD<256)minD=256; - if(maxD<=minD)return; - - float bestCorr=0;int bestD=bestDelay; - int n=std::min(frames,256); - for(int d=minD;d1e-10f&&c/denom>bestCorr){bestCorr=c/denom;bestD=d;} - } - if(bestCorr>0.05f)bestDelay=(bestDelay*3+bestD)/4; - } - - void diag(){ - diagCounter++; - if(diagCounter%100!=0)return; - char m[128]; - { - std::lock_guard lk(refMutex); - sprintf(m,"[AEC] diag: delay=%d gain=%.4f refWritten=%llu total=%d",bestDelay,estimatedGain,(unsigned long long)refWritten,totalProcessed); - } - errCb.NonBlockingCall([m](Napi::Env e,Napi::Function cb){cb.Call({Napi::String::New(e,m)});}); - } - void process(BYTE* data,UINT32 frames){ - totalProcessed+=frames; const int ch=mixFormat&&mixFormat->nChannels>0?mixFormat->nChannels:2; const int outCh=2; - // Process-loopback already excludes Pair's process tree, so call playback is - // not in this mix. Keep a full stereo pass-through for music/game audio - // instead of collapsing to mono or running a soft canceller that can - // smear desktop sound. float* buf=(float*)calloc((size_t)frames*(size_t)outCh,sizeof(float)); if(!buf)return; - if(mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT){ - float* f=(float*)data; - for(UINT32 i=0;i1?f[i*ch+1]:L; - buf[i*outCh+0]=L; - buf[i*outCh+1]=R; + + auto sampleAt=[&](UINT32 i,int c)->float{ + if(mixFormat->wFormatTag==WAVE_FORMAT_IEEE_FLOAT){ + float* f=(float*)data; + return f[i*ch+std::min(c,ch-1)]; } - }else if(mixFormat->wBitsPerSample==16){ - INT16* ps=(INT16*)data; - for(UINT32 i=0;i1?ps[i*ch+1]/32768.0f:L; - buf[i*outCh+0]=L; - buf[i*outCh+1]=R; + if(mixFormat->wBitsPerSample==16){ + INT16* ps=(INT16*)data; + return ps[i*ch+std::min(c,ch-1)]/32768.0f; } - }else{ INT32* pl=(INT32*)data; - for(UINT32 i=0;i1?pl[i*ch+1]/2147483648.0f:L; - buf[i*outCh+0]=L; - buf[i*outCh+1]=R; - } + return pl[i*ch+std::min(c,ch-1)]/2147483648.0f; + }; + + // Process-loopback exclusion already omits Pair's process tree. + for(UINT32 i=0;i1?sampleAt(i,1):L; + buf[i*outCh+0]=L; + buf[i*outCh+1]=R; } UINT32 fCopy=frames; @@ -284,9 +225,8 @@ class Capture { if(captureClient)captureClient->Release(); if(audioClient)audioClient->Release(); if(mixFormat)CoTaskMemFree(mixFormat); - if(device)device->Release(); - if(enumerator)enumerator->Release(); if(comInitialized)CoUninitialize(); + captureEvent=nullptr;captureClient=nullptr;audioClient=nullptr;mixFormat=nullptr;comInitialized=false; } }; diff --git a/app.js b/app.js index 3bc5810..36e9e3d 100644 --- a/app.js +++ b/app.js @@ -297,7 +297,7 @@ function buildGifPicker(){ return wrap; } function giphyFetch(endpoint,type,query,offset){ - const apiKey=window._giphyKey||'LtCRMfaqI1JFzONkJJFRJ8ktT3EdOoTL'; + const apiKey=window.pairEnv?.giphyKey||window._giphyKey||'';if(!apiKey)return Promise.resolve({data:[]}); const base=type==='stickers'?'stickers':'gifs'; const off=offset?`&offset=${offset}`:''; if(giphyFetch._cooldown>Date.now())return Promise.resolve({data:[]}); @@ -305,14 +305,14 @@ function giphyFetch(endpoint,type,query,offset){ return fetch(url,{credentials:'omit'}).then(r=>{if(r.status===429){giphyFetch._cooldown=Date.now()+10000;console.warn('giphy 429, cooling 10s');return{data:[]}}if(!r.ok)throw new Error('GIPHY '+r.status);return r.json()}).catch(()=>({data:[]})); } function klipyFetch(type,query,offset){ - const key='wDEDuoSRgy4oajhdMGJ7gtS2cFBB3DtWULsUYodKIRhcXvHreSPr6eNM3nm0oWc1'; + const key=window.pairEnv?.klipyKey||'';if(!key)return Promise.resolve({results:[]}); const params=new URLSearchParams({key,limit:'24',contentfilter:'off',locale:'en',media_filter:'gif,tinygif,webm,tinywebm'}); if(type==='stickers')params.set('searchfilter','sticker'); if(offset)params.set('page',Math.floor(offset/24)+1); const url=query?`https://api.klipy.com/v2/search?${params}&q=${encodeURIComponent(query)}`:`https://api.klipy.com/v2/featured?${params}`; return fetch(url,{headers:{Accept:'application/json'},credentials:'omit'}).then(r=>{if(!r.ok){console.warn('klipy err',r.status);return{results:[]}}return r.json()}).catch(e=>{console.warn('klipy fail',e.message);return{results:[]}}); } -function klipyShare(id){try{fetch(`https://api.klipy.com/v1/registershare?key=wDEDuoSRgy4oajhdMGJ7gtS2cFBB3DtWULsUYodKIRhcXvHreSPr6eNM3nm0oWc1&id=${id}`)}catch{}} +function klipyShare(id){const key=window.pairEnv?.klipyKey;if(!key)return;try{fetch(`https://api.klipy.com/v1/registershare?key=${encodeURIComponent(key)}&id=${id}`)}catch{}} function giphyAnalytics(giphyId,type){ try{fetch('https://api.giphy.com/v1/analytics/action/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action_type:'SENT',action_object_type:type==='stickers'?'sticker':'gif',action_object_id:giphyId})})}catch{} } @@ -431,8 +431,8 @@ function fileBus(){return (streamWs&&streamWs.readyState===WebSocket.OPEN)?strea // Direct pairing is STUN-only by default. In the desktop app, validated custom // TURN settings are supplied by the preload bridge; browser builds retain the // safe default rather than depending on a Node `process` global. -const DEFAULT_ICE_SERVERS=[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'}]; -const ICE_SERVERS=Array.isArray(window.pairEnv?.iceServers)&&window.pairEnv.iceServers.length?window.pairEnv.iceServers:DEFAULT_ICE_SERVERS; +const DEFAULT_ICE_SERVERS=(window.pairHelpers&&window.pairHelpers.DEFAULT_ICE_SERVERS)||[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'}]; +const ICE_SERVERS=(window.pairHelpers?window.pairHelpers.mergeIceServers(DEFAULT_ICE_SERVERS,window.pairEnv&&window.pairEnv.iceServers):DEFAULT_ICE_SERVERS); function setupPeer(){ // Close previous pc and associated resources if reconnecting (e.g. peer-left → peer-ready). // Null pc first so the old pc's onconnectionstatechange handler bails (sees !pc). @@ -445,7 +445,54 @@ function setupPeer(){ try{oldPc.close()}catch{} } pc=new RTCPeerConnection({iceServers:ICE_SERVERS});pc.onicecandidate=()=>{}; let wasEverConnected=false; - pc.onconnectionstatechange=()=>{if(!pc)return;if(pc.connectionState==='connected'){screenBtn.disabled=false;screenPreset.disabled=false;if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null} if(!wasEverConnected){wasEverConnected=true;if(reconnectCall){reconnectCall=false;if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null}callActive=false;startCall()}}else{setStatus('Connected directly',true);friendLeftNotified=false}}if(['failed','disconnected','closed'].includes(pc.connectionState)){screenBtn.disabled=true;screenPreset.disabled=true;if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null}setParticipant(participantFriend,false);setStatus(pc.connectionState);if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')}};if(pc.connectionState==='connecting'){pairHint.textContent='Negotiating peer connection (ICE '+ (pc.iceConnectionState||'') +')…';armConnectTimeout()}};pc.oniceconnectionstatechange=()=>{if(pc.iceConnectionState==='failed'){pairHint.textContent='Peer connection failed (ICE '+(pc.iceConnectionState||'')+'). NAT/network blocks a direct link and the TURN relay could not be reached. Both must be on v1.0.0+, and your network must allow the TURN relay.'}else if(pc.iceConnectionState==='checking'||pc.iceConnectionState==='connected'){pairHint.textContent='Negotiating peer connection (ICE '+(pc.iceConnectionState||'')+' )…'}};pc.ondatachannel=e=>{if(e.channel.label==='chat')chat=e.channel;else files=e.channel;wire()}; + let iceRestartTimer=null,iceRestartAttempts=0; + pc.onconnectionstatechange=()=>{ + if(!pc)return; + if(pc.connectionState==='connected'){ + screenBtn.disabled=false;screenPreset.disabled=false; + if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null} + if(iceRestartTimer){clearTimeout(iceRestartTimer);iceRestartTimer=null} + iceRestartAttempts=0; + if(!wasEverConnected){ + wasEverConnected=true; + if(reconnectCall){reconnectCall=false;if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null}callActive=false;startCall()} + }else{setStatus('Connected directly',true);friendLeftNotified=false} + } + if(pc.connectionState==='disconnected'){ + pairHint.textContent='Connection interrupted — trying to recover…'; + if(!iceRestartTimer&&iceRestartAttempts<2){ + iceRestartTimer=setTimeout(async()=>{ + iceRestartTimer=null; + if(!pc||pc.connectionState==='connected'||pc.connectionState==='closed')return; + iceRestartAttempts++; + try{ + const offer=await pc.createOffer({iceRestart:true}); + if(!pc||pc.connectionState==='closed')return; + await pc.setLocalDescription({type:'offer',sdp:patchSdp(offer.sdp)}); + await waitIce(); + if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-offer',sdp:pc.localDescription.sdp}})); + else if(chat?.readyState==='open')send({t:'reneg-offer',sdp:pc.localDescription.sdp}); + logCallEvent('Recovering connection…'); + }catch(e){if(window.pairHelpers)window.pairHelpers.debugLog('ice restart failed',e)} + },1500); + } + } + if(pc.connectionState==='failed'||pc.connectionState==='closed'){ + screenBtn.disabled=true;screenPreset.disabled=true; + if(pc._connectTimer){clearTimeout(pc._connectTimer);pc._connectTimer=connectTimer=null} + if(iceRestartTimer){clearTimeout(iceRestartTimer);iceRestartTimer=null} + setParticipant(participantFriend,false);setStatus(pc.connectionState); + if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')} + if(pc.connectionState==='failed')pairHint.textContent='Peer connection failed. Check network/NAT, or set PAIR_TURN for a relay.'; + } + if(pc.connectionState==='connecting'){pairHint.textContent='Negotiating peer connection (ICE '+ (pc.iceConnectionState||'') +')…';armConnectTimeout()} + }; + pc.oniceconnectionstatechange=()=>{ + if(!pc)return; + if(pc.iceConnectionState==='failed'){pairHint.textContent='Peer connection failed (ICE failed). NAT/network may be blocking the link; configure PAIR_TURN if needed.'} + else if(pc.iceConnectionState==='checking'||pc.iceConnectionState==='connected'){pairHint.textContent='Negotiating peer connection (ICE '+(pc.iceConnectionState||'')+' )…'} + }; + pc.ondatachannel=e=>{if(e.channel.label==='chat')chat=e.channel;else files=e.channel;wire()}; // If WebRTC can't establish within ~25s (e.g. TURN unreachable / blocked // network), surface a clear message instead of hanging on "Connecting…" forever. let connectTimer=null;pc._connectTimer=null;function armConnectTimeout(){if(connectTimer||pc.connectionState==='connected')return;connectTimer=setTimeout(()=>{pc._connectTimer=null;if(pc&&pc.connectionState!=='connected'&&pc.connectionState!=='failed'&&pc.connectionState!=='closed'){pairHint.textContent='Still connecting… if this persists, one of you is behind a strict NAT/firewall that blocks the peer connection. Try a different network or add a TURN server.'}},25000);pc._connectTimer=connectTimer} @@ -466,9 +513,10 @@ function setupPeer(){ // Keep a reference so we can close the AudioContext on disconnect pc._silentAudioCtx=silentCtx; }catch(e){console.warn('Silent audio track failed, using addTransceiver:',e);try{audioTransceiver=pc.addTransceiver('audio',{direction:'sendrecv'})}catch(e2){console.warn('addTransceiver also failed:',e2);audioTransceiver=null}} + if(window.pairHelpers)window.pairHelpers.debugLog('setupPeer transceivers',pc.getTransceivers().length); logCallEvent('Diag: setupPeer transceivers='+pc.getTransceivers().length+' audioTr='+(audioTransceiver?'ok:mid='+audioTransceiver.mid:'null')); let gestureGuard=false,screenGestureGuard=false; - pc.ontrack=e=>{logCallEvent('Diag: ontrack kind='+e.track.kind);try{const stream=e.streams[0]||new MediaStream([e.track]); + pc.ontrack=e=>{if(window.pairHelpers)window.pairHelpers.debugLog('ontrack',e.track.kind);logCallEvent('Diag: ontrack kind='+e.track.kind);try{const stream=e.streams[0]||new MediaStream([e.track]); // Keep remoteScreenExpected true until screen-end so audio that arrives after // the video track still routes to the screen element instead of the voice sink. const screenStreamId=remoteScreen.srcObject?.id; @@ -496,18 +544,20 @@ function setupPeer(){ } if(e.track.kind==='audio'){logCallEvent('Audio track received from friend');if(remoteAudio.srcObject){try{remoteAudio.srcObject.getAudioTracks().forEach(t=>t.onended=null)}catch{}}if(remoteAudio.srcObject&&remoteAudio.srcObject!==stream){try{remoteAudio.srcObject.addTrack(e.track)}catch{}}else remoteAudio.srcObject=stream;e.track.onended=()=>{if(!friendLeftNotified){friendLeftNotified=true;playSound('leave')}logCallEvent('Friend left the call');callStatus.textContent='Friend left the call';callStatus.className='call-status'};if(!callActive){setRemoteCallAudio(false);return}setRemoteCallAudio(true);if(!gestureGuard){gestureGuard=true;document.addEventListener('pointerdown',()=>setRemoteCallAudio(callActive),{once:true});document.addEventListener('keydown',()=>setRemoteCallAudio(callActive),{once:true})}}else if(e.track.kind==='video'){remoteScreen.hidden=false;try{remoteScreen.srcObject=stream;remoteScreen.play()}catch{};e.track.onended=()=>{if(remoteScreen.srcObject===stream){remoteScreen.srcObject=null;remoteScreen.hidden=true}}}}catch{}}; } -async function waitIce(){if(pc.iceGatheringState==='complete')return;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;pc?.removeEventListener('icegatheringstatechange',f);clearTimeout(timeout);resolve()};const f=()=>{if(pc?.iceGatheringState==='complete')finish()};const timeout=setTimeout(finish,5000);pc.addEventListener('icegatheringstatechange',f)})} -function patchOpusSdp(sdp){return sdp.replace(/a=fmtp:111[^\r\n]*/g,m=>{if(!m.includes('maxaveragebitrate'))m+='; maxaveragebitrate=256000';else m=m.replace(/maxaveragebitrate=\d+/,'maxaveragebitrate=256000');if(!m.includes('maxplaybackrate'))m+='; maxplaybackrate=48000';if(!m.includes('useinbandfec'))m+='; useinbandfec=1';if(!m.includes('usedtx'))m+='; usedtx=0';if(!m.includes('stereo'))m+='; stereo=1';else m=m.replace(/stereo=[01]/,'stereo=1');if(!m.includes('sprop-stereo'))m+='; sprop-stereo=1';else m=m.replace(/sprop-stereo=[01]/,'sprop-stereo=1');return m})} +async function waitIce(){if(pc.iceGatheringState==='complete')return;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;pc?.removeEventListener('icegatheringstatechange',f);clearTimeout(timeout);resolve()};const f=()=>{if(pc?.iceGatheringState==='complete')finish()};const timeout=setTimeout(finish,15000);pc.addEventListener('icegatheringstatechange',f)})} +function patchOpusSdp(sdp){return window.pairHelpers?window.pairHelpers.patchOpusSdp(sdp,96000):sdp} // Sender parameters are the primary limiter. This SDP fallback keeps browsers // that ignore those parameters from silently collapsing a high-motion share to -// the old 16 Mbps ceiling. +// an overly low ceiling on older clients. function patchVideoSdp(sdp){ + const kbps=Math.round(Math.max(2,Math.min(120,screenBitrateMbps||12))*1000); + if(window.pairHelpers)return window.pairHelpers.patchVideoSdp(sdp,kbps); sdp=sdp.replace(/\r\n/g,'\n'); return sdp.replace(/^m=video .*\n(?:[^m].*\n)*/gm,m=>{ let section=m; section=section.replace(/\nb=AS:\d+/g,''); section=section.replace(/\na=x-google-(?:min|max)-bitrate:\d+/g,''); - return section+'a=x-google-max-bitrate:140000\n'; + return section+'a=x-google-max-bitrate:'+kbps+'\n'; }); } function patchSdp(sdp){return patchVideoSdp(patchOpusSdp(sdp))} @@ -662,7 +712,7 @@ enableLocalTestControls(); async function ss(key){if(window.pairSettings){try{return await window.pairSettings.get(key)}catch{}}try{return localStorage.getItem('pair.'+key)}catch{}} async function ssSet(key,val){if(window.pairSettings){try{await window.pairSettings.set(key,val);return}catch{}}try{if(val==null)localStorage.removeItem('pair.'+key);else localStorage.setItem('pair.'+key,val)}catch{}} -let screenCursor='always',screenContentHint='motion',screenBitrateMbps=20,screenCodec='auto',shareResolution=1080,shareFrameRate=30; +let screenCursor='always',screenContentHint='motion',screenBitrateMbps=12,screenCodec='auto',shareResolution=720,shareFrameRate=30; function openSettingsTab(name){document.querySelectorAll('.settings-tab').forEach(tab=>{const active=tab.dataset.settingsTab===name;tab.classList.toggle('active',active);tab.setAttribute('aria-selected',String(active))});document.querySelectorAll('.settings-page').forEach(page=>{const active=page.dataset.settingsPage===name;page.classList.toggle('active',active);page.hidden=!active})} function addScreenShareSettings(){ const tab=document.createElement('button');tab.type='button';tab.className='settings-tab';tab.dataset.settingsTab='screen';tab.setAttribute('role','tab');tab.setAttribute('aria-selected','false');tab.textContent='Screen sharing'; @@ -672,7 +722,7 @@ function addScreenShareSettings(){ const bitrate=$('#screenBitrateSetting'),bitrateValue=$('#screenBitrateValue'),codec=$('#screenCodecSetting'),contentHint=$('#screenContentHintSetting'),cursor=$('#screenCursorSetting'); const updateBitrate=()=>{screenBitrateMbps=Math.max(2,Math.min(120,Number(bitrate.value)||20));bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';ssSet('screenBitrate',String(screenBitrateMbps))}; bitrate.oninput=updateBitrate;enableRangeDrag(bitrate);codec.onchange=()=>{screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(codec.value)?codec.value:'auto';ssSet('screenCodec',screenCodec)};contentHint.onchange=()=>{screenContentHint=contentHint.value==='detail'?'detail':'motion';ssSet('screenContentHint',screenContentHint)};cursor.onchange=()=>{screenCursor=['always','motion','never'].includes(cursor.value)?cursor.value:'always';ssSet('screenCursor',screenCursor)}; - return async()=>{const b=Number(await ss('screenBitrate'));screenBitrateMbps=Number.isFinite(b)&&b>=2&&b<=120?b:20;bitrate.value=String(screenBitrateMbps);bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';const v=await ss('screenCodec');screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(v)?v:'auto';codec.value=screenCodec;const c=await ss('screenCursor');screenCursor=['always','motion','never'].includes(c)?c:'always';cursor.value=screenCursor;const h=await ss('screenContentHint');screenContentHint=h==='detail'?'detail':'motion';contentHint.value=screenContentHint}; + return async()=>{const b=Number(await ss('screenBitrate'));screenBitrateMbps=Number.isFinite(b)&&b>=2&&b<=120?b:12;bitrate.value=String(screenBitrateMbps);bitrate.style.setProperty('--range-fill',((screenBitrateMbps-2)/118*100)+'%');bitrateValue.textContent=screenBitrateMbps+' Mbps';const v=await ss('screenCodec');screenCodec=['auto','H264','H265','VP9','AV1','VP8'].includes(v)?v:'auto';codec.value=screenCodec;const c=await ss('screenCursor');screenCursor=['always','motion','never'].includes(c)?c:'always';cursor.value=screenCursor;const h=await ss('screenContentHint');screenContentHint=h==='detail'?'detail':'motion';contentHint.value=screenContentHint}; } const restoreScreenShareSettings=addScreenShareSettings(); document.querySelectorAll('.settings-tab').forEach(tab=>tab.onclick=()=>openSettingsTab(tab.dataset.settingsTab)); @@ -745,10 +795,22 @@ async function automaticPair(kind){ signaling.onerror=()=>pairHint.textContent='Could not reach the signaling server. Check the address and firewall.'; signaling.onmessage=async event=>{try{const message=JSON.parse(event.data); if(message.type==='full'){pairHint.textContent='That room already has two people.';return} + if(message.type==='peer-left'){ + logCallEvent('Friend left the room'); + pairHint.textContent='Friend left the room. Waiting for them to rejoin…'; + setParticipant(participantFriend,false); + if(screenActive||screenStarting||screenStream)stopScreenShare(true); + try{if(pc){pc.close()}}catch{} + if(pc&&pc._silentAudioCtx)try{pc._silentAudioCtx.close()}catch{} + pc=chat=files=null;audioTransceiver=null;sharedKey=null; + try{remoteAudio.srcObject=null}catch{};try{remoteScreen.srcObject=null}catch{};remoteScreen.hidden=true; + setStatus('Waiting for friend'); + return; + } if(message.type==='peer-ready'&&role==='host'){ reconnectCall=callActive;setupPeer();const kp=await keyPair();if(!pc)return;pc._kp=kp;setupChannels(); const offer=await pc.createOffer();if(!pc)return;await pc.setLocalDescription({type:'offer',sdp:patchSdp(offer.sdp)});if(!pc)return;await waitIce();if(!signaling)return; - logCallEvent('Diag: offer has m=audio=' + (pc.localDescription.sdp.includes('m=audio')?'yes':'NO')); + signaling.send(JSON.stringify({type:'signal',payload:{kind:'offer',sdp:pc.localDescription.sdp,pub:await exportPub(kp.publicKey)}})); openStreamRelay(address,room);pairHint.textContent='Offer sent. Connecting…'; // If the friend never answers (wrong role, different room, or an old build @@ -769,20 +831,14 @@ async function automaticPair(kind){ // sender. Also update audioTransceiver to the MATCHED transceiver (with // non-null mid) so startCall uses it — the one from addTransceiver in // setupPeer has mid=null and would send on an un-negotiated path. - pc.getTransceivers().filter(t=>t.receiver.track?.kind==='audio').forEach(t=>{try{if(t.direction!=='sendrecv'){t.setDirection('sendrecv');logCallEvent('Diag: set audioTr direction to sendrecv (was '+t.direction+')')}}catch(e){logCallEvent('Diag: setDirection error: '+e.message)}}); + pc.getTransceivers().filter(t=>t.receiver.track?.kind==='audio').forEach(t=>{try{if(t.direction!=='sendrecv')t.setDirection('sendrecv')}catch{}}); const matched=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matched)audioTransceiver=matched; - logCallEvent('Diag: before createAnswer transceivers='+pc.getTransceivers().length+' audioTr='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio')?'ok:dir='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio').direction):'null')); const a=await pc.createAnswer();if(!pc)return;await pc.setLocalDescription({type:'answer',sdp:patchSdp(a.sdp)});if(!pc)return;await waitIce();if(!signaling)return; - logCallEvent('Diag: answer has m=audio=' + (pc.localDescription.sdp.includes('m=audio')?'yes':'NO')); signaling.send(JSON.stringify({type:'signal',payload:{kind:'answer',sdp:pc.localDescription.sdp,pub:await exportPub(kp.publicKey)}})); openStreamRelay(address,room);pairHint.textContent='Answer sent. Connecting…' }else if(remote.kind==='answer'&&role==='host'){ - logCallEvent('Diag: before setRD(answer) transceivers='+pc.getTransceivers().length+' audioTr='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio')?'ok:dir='+(pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio').direction):'null')); await pc.setRemoteDescription({type:'answer',sdp:remote.sdp});if(!pc)return;if(!await derive(pc._kp,remote.pub)){disconnectRoom();pairHint.textContent='Security code was not confirmed.';return} - logCallEvent('Diag: after setRD(answer)'); - const matched=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matched)audioTransceiver=matched; - const cd=matched?matched.currentDirection:'none'; - logCallEvent('Diag: audio currentDir='+cd); + const matchedHost=pc.getTransceivers().find(t=>t.receiver.track?.kind==='audio'&&t.mid);if(matchedHost)audioTransceiver=matchedHost; // If the friend's answer didn't include an audio sender, startCall will // add a transceiver and renegotiate instead of relying on the unmatched one. openStreamRelay(address,room);pairHint.textContent='Secure connection established.' @@ -795,7 +851,7 @@ async function automaticPair(kind){ if(renegPending&&role==='join'){renegotiating++;renegPending=false} try{if(!pc)return;await pc.setRemoteDescription({type:'offer',sdp:remote.sdp});if(!pc)return;const a=await pc.createAnswer();if(!pc)return;await pc.setLocalDescription({type:'answer',sdp:patchSdp(a.sdp)});if(!pc)return;await waitIce();if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-answer',sdp:pc.localDescription.sdp}}))}catch(e){console.warn('reneg-offer error',e)} }else if(remote.kind==='reneg-answer'){ - try{if(!pc)return;await pc.setRemoteDescription({type:'answer',sdp:remote.sdp})}catch(e){console.warn('reneg-answer error',e)} + try{if(!pc)return;await pc.setRemoteDescription({type:'answer',sdp:remote.sdp});clearRenegPending()}catch(e){console.warn('reneg-answer error',e)} } } }catch(e){console.warn('signaling message error',e);pairHint.textContent='Connection setup failed: '+(e&&e.message||e)}}; @@ -872,11 +928,11 @@ async function startCall(){ // Resolve to the transceiver that owns it so sender.sender is correct. const resolvedTr=tr&&tr.mid===undefined&&!tr.sender?allTransceivers.find(t=>t.sender===tr)||tr:tr; const sender=resolvedTr?resolvedTr.sender:null; - logCallEvent('Diag: startCall transceivers='+allTransceivers.length+' audioTr='+(tr?'ok:mid='+tr.mid+' dir='+tr.direction:'null')+' sender='+(sender?'ok':'null')); + if(!sender){try{send({t:'call-end'})}catch{};endCall(true);callStatus.textContent='No audio sender available';callStatus.className='call-status';return} try{await sender.replaceTrack(track)}catch(e){try{send({t:'call-end'})}catch{};endCall(true);callStatus.textContent='Failed to attach mic: '+(e?.message||e);callStatus.className='call-status';return} // Configure Opus for maximum quality — 510 kbps (spec limit), 48 kHz, FEC - try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];p.encodings[0].maxBitrate=256000;if(p.codecs)p.codecs.forEach(c=>{if(c.mimeType.toLowerCase()==='audio/opus'){c.maxptime=120;c.ptime=20;if(c.parameters){c.parameters.maxaveragebitrate=256000;c.parameters.maxplaybackrate=48000;c.parameters.useinbandfec=1;c.parameters.usedtx=0;c.parameters.cbr=1;c.parameters.stereo=1;c.parameters['sprop-stereo']=1;c.parameters.spropmaxcapturerate=48000}}});await sender.setParameters(p)}}catch(e){console.warn('opus params:',e)} + try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];p.encodings[0].maxBitrate=96000;if(p.codecs)p.codecs.forEach(c=>{if(c.mimeType.toLowerCase()==='audio/opus'){c.maxptime=120;c.ptime=20;if(c.parameters){c.parameters.maxaveragebitrate=96000;c.parameters.maxplaybackrate=48000;c.parameters.useinbandfec=1;c.parameters.usedtx=1;c.parameters.cbr=0;c.parameters.stereo=1;c.parameters['sprop-stereo']=1;c.parameters.spropmaxcapturerate=48000}}});await sender.setParameters(p)}}catch(e){console.warn('opus params:',e)} // endCall may have run while we were awaiting getUserMedia or replaceTrack // (e.g. user clicked Stop Voice or the connection dropped). The generation // counter callGen is incremented by every endCall call. If it changed, bail. @@ -936,11 +992,8 @@ let renegotiating=0; // we resolve it by role. The joiner defers (answers the host's offer instead of // insisting on its own); the host wins. role is deterministic across peers. let renegPending=false; -// A screen share has two negotiations: video immediately, then audio once the -// clean capture route is ready. Do not create the latter offer while the -// former is still awaiting its answer. Chrome rejects createOffer in -// have-local-offer, which made the audio sender local-only and resulted in a -// silent share. +// Do not createOffer while a previous renegotiation is still have-local-offer; +// Chrome rejects that and can leave a new sender local-only (silent share). function waitForStablePeer(target=pc,timeout=10000){ if(!target||target.signalingState==='closed')return Promise.resolve(false); if(target.signalingState==='stable')return Promise.resolve(true); @@ -955,7 +1008,7 @@ function waitForStablePeer(target=pc,timeout=10000){ }); } async function renegotiate(){ - if(!pc||(!signaling&&chat?.readyState!=='open'))return; + if(!pc||(!signaling&&chat?.readyState!=='open'))return false; const target=pc; if(!await waitForStablePeer(target)){ console.warn('renegotiate skipped: peer did not return to stable'); @@ -974,84 +1027,123 @@ async function renegotiate(){ if(signaling)signaling.send(JSON.stringify({type:'signal',payload:{kind:'reneg-offer',sdp:pc.localDescription.sdp}})); else if(chat?.readyState==='open')send({t:'reneg-offer',sdp:pc.localDescription.sdp}); else{renegPending=false;return false} + // Keep renegPending true until the remote answer arrives (or this gen is + // superseded) so glare handling can still detect an in-flight offer. return true; - }catch(e){console.warn('renegotiate error',e);return false} - finally{if(myId===renegotiating)renegPending=false} + }catch(e){console.warn('renegotiate error',e);renegPending=false;return false} } +function clearRenegPending(){renegPending=false} // Capture desktop sound for screen share while keeping Pair voice out of that -// mix. Windows uses process-loopback that excludes Pair's process tree; Linux -// uses a dedicated PipeWire sink. The voice call stays on its own WebRTC track. +// mix. Windows uses process-loopback exclusion only; older Windows without that +// API shares video only rather than falling back to endpoint loopback. +// Prefer AudioWorklet over ScriptProcessor. +async function loadShareAudioWorklet(ctx){ + const candidates=[]; + try{candidates.push(new URL('share-audio-worklet.js',window.location.href).href)}catch{} + candidates.push('share-audio-worklet.js'); + for(const url of candidates){ + try{await ctx.audioWorklet.addModule(url);return true}catch{} + try{ + const res=await fetch(url,{cache:'force-cache'}); + if(!res.ok)continue; + const text=await res.text(); + const blobUrl=URL.createObjectURL(new Blob([text],{type:'application/javascript'})); + try{await ctx.audioWorklet.addModule(blobUrl);return true} + finally{try{URL.revokeObjectURL(blobUrl)}catch{}} + }catch{} + } + return false; +} + async function setupNativeScreenCapture(){ if(!window.pairCapture){ console.warn('[AUDIO] isolated desktop capture unavailable; refusing full-mix loopback'); + logCallEvent('Computer sound unavailable on this system'); return null; } - let ctx,dest,addonData=false,aecTimedOut=false; + let ctx,dest,addonData=false,captureTimedOut=false,workletNode; try{ ctx=new AudioContext({sampleRate:48000}); if(ctx.state==='suspended'){try{await ctx.resume()}catch{}} dest=ctx.createMediaStreamDestination();dest.channelCount=2; - // Interleaved stereo ring: [L,R,L,R,...] - const RS=96000;const cleanBuf=new Float32Array(RS*2); - let wp=0,avail=0; - const unsubClean=window.pairCapture.onCleanAudio((buf,frames)=>{ - if(aecTimedOut)return; + const feed=(arr,frames)=>{ + if(captureTimedOut)return; addonData=true; - const arr=new Float32Array(buf); - // Prefer interleaved stereo from the native addon. Older builds emit mono - // (one float per frame); lift that to L/R so the share track stays stereo. const stereo=arr.length>=frames*2; + const interleaved=new Float32Array(frames*2); + for(let i=0;i{ for(let i=0;ifeed(new Float32Array(buf),frames)); + const unsubError=window.pairCapture.onError(msg=>{ + if(window.pairHelpers)window.pairHelpers.debugLog('capture error',msg); }); - const unsubError=window.pairCapture.onError(msg=>console.warn('[AUDIO] capture error:',msg)); window.pairCapture.start(); - // Attach as soon as the first clean packets arrive so share audio starts - // with the video instead of waiting on a fixed multi-second delay. + + let usedWorklet=false; + if(await loadShareAudioWorklet(ctx)){ + workletNode=new AudioWorkletNode(ctx,'pair-share-audio',{numberOfInputs:0,numberOfOutputs:1,outputChannelCount:[2]}); + workletNode.connect(dest); + usedWorklet=true; + }else{ + if(window.pairHelpers)window.pairHelpers.debugLog('worklet unavailable; using script processor'); + const op=ctx.createScriptProcessor(1024,0,2); + op.onaudioprocess=e=>{ + const L=e.outputBuffer.getChannelData(0); + const R=e.outputBuffer.getChannelData(1); + if(availsetTimeout(r,40)); if(!addonData){ console.warn('[AUDIO] isolated desktop capture produced no samples; sharing video only'); - aecTimedOut=true; + logCallEvent('Computer sound unavailable — sharing video only'); + captureTimedOut=true; if(unsubClean)unsubClean();if(unsubError)unsubError(); window.pairCapture.stop(); + try{if(workletNode)workletNode.disconnect()}catch{} + try{if(dest._pairScript)dest._pairScript.disconnect()}catch{} if(ctx)try{ctx.close()}catch{} return null; } - const B=1024; - const op=ctx.createScriptProcessor(B,0,2); - op.onaudioprocess=e=>{ - const L=e.outputBuffer.getChannelData(0); - const R=e.outputBuffer.getChannelData(1); - if(avail{if(unsubClean)unsubClean();if(unsubError)unsubError();window.pairCapture.stop();try{op.disconnect()}catch{}}; + screenCaptureCleanup=()=>{ + if(unsubClean)unsubClean();if(unsubError)unsubError(); + window.pairCapture.stop(); + try{if(workletNode)workletNode.disconnect()}catch{} + try{if(dest._pairScript)dest._pairScript.disconnect()}catch{} + }; + if(window.pairHelpers)window.pairHelpers.debugLog('share audio ready','process',usedWorklet?'worklet':'script'); return t||null; }catch(e){ console.warn('[AUDIO] isolated desktop capture failed:',e?.message||e); + logCallEvent('Computer sound failed to start'); if(ctx)try{ctx.close()}catch{} try{window.pairCapture.stop()}catch{} return null; @@ -1063,19 +1155,45 @@ function cleanupNativeScreenCapture(){ if(screenOutDest){screenOutDest=null} if(screenOutCtx){try{screenOutCtx.close()}catch{};screenOutCtx=null} } -async function linuxShareAudioTrack(){ - const share=await window.pairEnv?.startLinuxShareAudio?.();if(!share)return null; + +async function linuxShareAudioTrack(expectedGen){ + const aborted=()=>expectedGen!=null&&expectedGen!==screenGen; + let sinkStarted=false; + const discardSink=()=>{if(!sinkStarted)return;sinkStarted=false;try{window.pairEnv?.stopLinuxShareAudio?.()}catch{}}; + // Electron can redact input labels until getUserMedia has been granted once. + // Probe and stop before creating the PipeWire share sink so enumerateDevices + // can see “Pair Share Audio” on a fresh profile or share-without-call. + try{ + const permissionProbe=await navigator.mediaDevices.getUserMedia({audio:true}); + permissionProbe.getTracks().forEach(t=>t.stop()); + }catch(e){console.warn('[AUDIO] unable to reveal PipeWire inputs:',e?.message||e)} + if(aborted())return null; + const share=await window.pairEnv?.startLinuxShareAudio?.(); + if(!share)return null; + sinkStarted=true; + if(aborted()){discardSink();return null} const label=typeof share==='string'?share:share.label||'Pair Share Audio'; // PipeWire may expose this as “Monitor of Pair Share Audio”, so match both // the friendly label and the stable Pair/Share words rather than one exact // desktop-specific spelling. - // Electron can redact input labels until getUserMedia has been granted once. - // Open and immediately stop the default input only to establish that grant; - // it is never added to the share. Without this, the Pair Share monitor is - // present but impossible to select on a first share or a fresh profile. - try{const permissionProbe=await navigator.mediaDevices.getUserMedia({audio:true});permissionProbe.getTracks().forEach(t=>t.stop())}catch(e){console.warn('[AUDIO] unable to reveal PipeWire inputs:',e?.message||e)} - let inputs=0,lastError='';for(let attempt=0;attempt<80;attempt++){const devices=await navigator.mediaDevices.enumerateDevices();inputs=devices.filter(d=>d.kind==='audioinput').length;const device=devices.find(d=>d.kind==='audioinput'&&(d.label.includes(label)||(/pair/i.test(d.label)&&/share/i.test(d.label))));if(device){try{const stream=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:device.deviceId},channelCount:{ideal:2},sampleRate:{ideal:48000},echoCancellation:false,noiseSuppression:false,autoGainControl:false}});const track=stream.getAudioTracks()[0]||null;if(track){track.enabled=true;return track}}catch(e){lastError=String(e?.name||e?.message||'capture rejected');console.warn('[AUDIO] Pair Share Audio device failed:',e)}}await new Promise(resolve=>setTimeout(resolve,150))} - screenAudioDebug=' · monitor unavailable ('+inputs+' inputs'+(lastError?', '+lastError:'')+')';console.warn('[AUDIO] Pair Share Audio monitor was not exposed by PipeWire');window.pairEnv?.stopLinuxShareAudio?.();return null; + for(let attempt=0;attempt<24;attempt++){ + if(aborted()){discardSink();return null} + const devices=await navigator.mediaDevices.enumerateDevices(); + if(aborted()){discardSink();return null} + const device=devices.find(d=>d.kind==='audioinput'&&(d.label.includes(label)||(/pair/i.test(d.label)&&/share/i.test(d.label)))); + if(device){ + try{ + const stream=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:device.deviceId},echoCancellation:false,noiseSuppression:false,autoGainControl:false}}); + if(aborted()){stream.getTracks().forEach(t=>t.stop());discardSink();return null} + const track=stream.getAudioTracks()[0]||null; + if(track){track.enabled=true;return track} + }catch(e){console.warn('[AUDIO] Pair Share Audio device failed:',e)} + } + await new Promise(resolve=>setTimeout(resolve,150)); + } + console.warn('[AUDIO] Pair Share Audio monitor was not exposed by PipeWire'); + discardSink(); + return null; } function startScreenStats(sender){ if(screenStatsTimer)clearInterval(screenStatsTimer);screenStatsLast=null; @@ -1121,84 +1239,103 @@ async function startScreenShare(){ // Desktop capture defaults to text/detail on some Chromium builds. Motion // tells the encoder to preserve changing game/action content instead. try{track.contentHint=screenContentHint}catch{} - // Add the video track + // Add the video track, prepare computer sound (if enabled), then renegotiate + // once so share audio and video land together instead of racing two offers. let sender; try{sender=pc.addTrack(track,stream);screenSenders=[sender]}catch{stream.getTracks().forEach(t=>t.stop());return} - // Audio attachment can take a moment while native capture warms up. Start - // video first, then renegotiate again only if a clean audio track is ready. - const attachShareAudio=async()=>{ - if(!screenAudioOn)return; + let audioReady=false; + if(screenAudioOn){ + screenStatus.textContent='Sharing… preparing sound'; let audioTrack=null; if(window.pairEnv?.platform==='linux'){ - audioTrack=await linuxShareAudioTrack(); + audioTrack=await linuxShareAudioTrack(gen); }else{ - // Drop any Chromium loopback that may have been granted unexpectedly. try{stream.getAudioTracks().forEach(t=>{try{t.stop()}catch{};try{stream.removeTrack(t)}catch{}})}catch{} try{audioTrack=await setupNativeScreenCapture()}catch(e){ console.warn('[AUDIO] clean capture failed:',e?.message||e); audioTrack=null; } } - if(!audioTrack){ - console.warn('[AUDIO] computer sound unavailable without echo risk; sharing video only'); - if(gen===screenGen&&screenActive){ - screenAudioDebug=' · sound unavailable';screenStatus.textContent=(screenStatus.textContent||'Sharing')+screenAudioDebug; - logCallEvent('Computer sound unavailable — sharing video only'); - } - return; - } const discardShareAudio=()=>{ try{audioTrack?.stop()}catch{} - try{stream.removeTrack(audioTrack)}catch{} + try{if(audioTrack)stream.removeTrack(audioTrack)}catch{} // A canceled/failed Linux attach must restore the desktop default sink // immediately; otherwise a late async result can leave audio routed to // an orphaned Pair Share sink after the user has already stopped. if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{} else cleanupNativeScreenCapture(); }; - if(gen!==screenGen||!screenActive||!pc){discardShareAudio();return} - try{ - audioTrack.enabled=true; - try{audioTrack.contentHint='music'}catch{} - try{stream.addTrack(audioTrack)}catch{} - const audioSender=pc.addTrack(audioTrack,stream); - screenSenders.push(audioSender); - try{ - const p=audioSender.getParameters(); - if(p){ - if(!p.encodings||!p.encodings.length)p.encodings=[{}]; - // Prefer higher bitrate stereo for game/music desktop sound; voice - // remains on the separate call track at its own Opus settings. - p.encodings[0].maxBitrate=192000; - await audioSender.setParameters(p); - } - }catch{} - if(!await renegotiate())throw new Error('audio negotiation did not start'); - if(gen!==screenGen||!screenActive||!pc)throw new Error('screen share ended during audio negotiation'); - logCallEvent('Computer sound sharing started');screenAudioDebug=' · sound live';screenStatus.textContent='Sharing'+screenAudioDebug; - }catch(e){ - console.warn('[AUDIO] addTrack failed:',e);if(gen===screenGen&&screenActive){screenAudioDebug=' · sound failed';screenStatus.textContent='Sharing'+screenAudioDebug} - const sender=screenSenders.find(s=>s.track===audioTrack); - if(sender){try{pc?.removeTrack(sender)}catch{};screenSenders=screenSenders.filter(s=>s!==sender)} + if(gen!==screenGen||!pc){ discardShareAudio(); + screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[]; + stream.getTracks().forEach(t=>t.stop()); + return; } - }; + if(audioTrack){ + try{ + audioTrack.enabled=true; + try{audioTrack.contentHint='music'}catch{} + try{stream.addTrack(audioTrack)}catch{} + const audioSender=pc.addTrack(audioTrack,stream); + screenSenders.push(audioSender); + try{ + const p=audioSender.getParameters(); + if(p){ + if(!p.encodings||!p.encodings.length)p.encodings=[{}]; + p.encodings[0].maxBitrate=192000; + await audioSender.setParameters(p); + } + }catch{} + audioReady=true; + }catch(e){ + console.warn('[AUDIO] addTrack failed:',e); + const senderGone=screenSenders.find(s=>s.track===audioTrack); + if(senderGone){try{pc?.removeTrack(senderGone)}catch{};screenSenders=screenSenders.filter(s=>s!==senderGone)} + discardShareAudio(); + } + }else{ + console.warn('[AUDIO] computer sound unavailable without echo risk; sharing video only'); + logCallEvent('Computer sound unavailable — sharing video only'); + } + } // Prefer codecs that are normally hardware accelerated. AV1 is excellent at // low bitrates but its software encoder is a frequent source of high CPU and // seconds of latency during desktop capture, so it stays a last fallback. - try{const tr=pc.getTransceivers().find(t=>t.sender===sender);if(tr){const caps=RTCRtpSender.getCapabilities('video');if(caps){const names=screenCodec==='auto'?['AV1','H265','H264','VP9','VP8']:[screenCodec,'H264','VP9','VP8'];const cs=names.map(name=>caps.codecs.find(c=>c.mimeType===`video/${name}`)).filter(Boolean);if(cs.length)tr.setCodecPreferences(cs)}}}catch(e){console.warn('[VIDEO] codec pref err:',e)} - try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];const maxBitrate=Math.round(Math.max(2,Math.min(120,screenBitrateMbps))*1000000);p.encodings[0].maxBitrate=maxBitrate;p.encodings[0].maxFramerate=fps;p.degradationPreference=screenContentHint==='detail'?'maintain-resolution':'maintain-framerate';await sender.setParameters(p);console.log('[VIDEO] bitrate='+(maxBitrate/1e6)+'Mbps '+fps+'fps '+p.degradationPreference)}else console.warn('[VIDEO] no params')}catch(e){console.warn('[VIDEO] setParams err:',e)} - if(gen!==screenGen||!pc){screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[];stream.getTracks().forEach(t=>t.stop());return} + try{const tr=pc.getTransceivers().find(t=>t.sender===sender);if(tr){const caps=RTCRtpSender.getCapabilities('video');if(caps){const names=window.pairHelpers?window.pairHelpers.preferredVideoCodecs(screenCodec):(screenCodec==='auto'?['H264','VP9','VP8','H265','AV1']:[screenCodec,'H264','VP9','VP8']);const cs=names.map(name=>caps.codecs.find(c=>c.mimeType===`video/${name}`)).filter(Boolean);if(cs.length)tr.setCodecPreferences(cs)}}}catch(e){console.warn('[VIDEO] codec pref err:',e)} + try{const p=sender.getParameters();if(p){if(!p.encodings||!p.encodings.length)p.encodings=[{}];const preset=screenPreset.value;const maxBitrate=Math.round(Math.max(2,Math.min(120,screenBitrateMbps))*1000000);p.encodings[0].maxBitrate=maxBitrate;p.encodings[0].maxFramerate=SCREEN_PRESETS[preset]?.frameRate?.max||30;p.degradationPreference=screenContentHint==='detail'?'maintain-resolution':'maintain-framerate';await sender.setParameters(p);if(window.pairHelpers)window.pairHelpers.debugLog('video bitrate',maxBitrate,p.degradationPreference)}else console.warn('[VIDEO] no params')}catch(e){console.warn('[VIDEO] setParams err:',e)} + if(gen!==screenGen||!pc){ + screenSenders.forEach(s=>{try{pc.removeTrack(s)}catch{}});screenSenders=[]; + stream.getTracks().forEach(t=>t.stop()); + if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{} + cleanupNativeScreenCapture(); + return; + } screenActive=true;screenAudioDebug=screenAudioOn?' · starting sound capture':' · sound off'; screenPreview.muted=true; screenPreview.srcObject=stream;screenPreview.hidden=false;try{screenPreview.play()}catch{} - screenBtn.textContent='Stop sharing';screenBtn.title='Stop screen sharing';screenStatus.textContent='Sharing'; + screenBtn.textContent='Stop sharing';screenBtn.title='Stop screen sharing'; startScreenStats(sender); try{send({t:'screen-start'})}catch{}; - logCallEvent('You started screen sharing'); track.onended=()=>{if(screenActive)stopScreenShare()}; - await renegotiate();if(gen!==screenGen)return;void attachShareAudio(); - }catch(e){screenStatus.textContent='Share failed';if(e.name!=='NotAllowedError')logCallEvent('Screen share error')} + // Wait until signaling is stable inside renegotiate before createOffer. + // Only claim computer-sound success when that renegotiation actually starts. + const renegOk=await renegotiate(); + if(gen!==screenGen)return; + const soundLive=audioReady&&renegOk; + screenStatus.textContent=soundLive?'Sharing':'Sharing · video only'; + if(audioReady&&!renegOk){ + console.warn('[AUDIO] share audio renegotiation did not start; sharing video only'); + logCallEvent('You started screen sharing'); + logCallEvent('Computer sound negotiation failed — sharing video only'); + }else{ + logCallEvent(soundLive?'You started screen sharing with computer sound':'You started screen sharing'); + } + }catch(e){ + screenStatus.textContent='Share failed'; + if(window.pairEnv?.platform==='linux')try{window.pairEnv.stopLinuxShareAudio?.()}catch{} + cleanupNativeScreenCapture(); + if(e.name!=='NotAllowedError')logCallEvent('Screen share error'); + } }finally{if(gen===screenGen)screenStarting=false} } async function stopScreenShare(fromEnd){ diff --git a/app_ids.txt b/app_ids.txt deleted file mode 100644 index e69de29..0000000 diff --git a/build.log b/build.log deleted file mode 100644 index a31d7ff..0000000 Binary files a/build.log and /dev/null differ diff --git a/build10.log b/build10.log deleted file mode 100644 index 5ce66c2..0000000 Binary files a/build10.log and /dev/null differ diff --git a/build11.log b/build11.log deleted file mode 100644 index 1699fe2..0000000 Binary files a/build11.log and /dev/null differ diff --git a/build12.log b/build12.log deleted file mode 100644 index a7a4c46..0000000 Binary files a/build12.log and /dev/null differ diff --git a/build13.log b/build13.log deleted file mode 100644 index 2a3fb56..0000000 Binary files a/build13.log and /dev/null differ diff --git a/build2.log b/build2.log deleted file mode 100644 index 9223005..0000000 Binary files a/build2.log and /dev/null differ diff --git a/build3.log b/build3.log deleted file mode 100644 index 89c45ea..0000000 Binary files a/build3.log and /dev/null differ diff --git a/build4.log b/build4.log deleted file mode 100644 index 8930566..0000000 Binary files a/build4.log and /dev/null differ diff --git a/build5.log b/build5.log deleted file mode 100644 index 30bd06f..0000000 Binary files a/build5.log and /dev/null differ diff --git a/build6.log b/build6.log deleted file mode 100644 index 1ad2866..0000000 Binary files a/build6.log and /dev/null differ diff --git a/build7.log b/build7.log deleted file mode 100644 index ef5f93d..0000000 Binary files a/build7.log and /dev/null differ diff --git a/build8.log b/build8.log deleted file mode 100644 index f872765..0000000 Binary files a/build8.log and /dev/null differ diff --git a/build9.log b/build9.log deleted file mode 100644 index 5ce66c2..0000000 Binary files a/build9.log and /dev/null differ diff --git a/html_ids.txt b/html_ids.txt deleted file mode 100644 index 6b4b910..0000000 Binary files a/html_ids.txt and /dev/null differ diff --git a/index.html b/index.html index 382ffa8..1129d12 100644 --- a/index.html +++ b/index.html @@ -136,6 +136,7 @@

Pair.

+ diff --git a/main.js b/main.js index 43165d4..9f584ed 100644 --- a/main.js +++ b/main.js @@ -330,7 +330,7 @@ function createWindow() { mainWin.webContents.setWindowOpenHandler(({ url }) => { try { const parsed = new URL(url); - if (parsed.protocol === 'https:' || parsed.protocol === 'http:') shell.openExternal(parsed.href); + if (parsed.protocol === 'https:') shell.openExternal(parsed.href); } catch {} return { action: 'deny' }; }); @@ -396,14 +396,11 @@ function loadNativeCapture(win) { let lastErr = ''; for (const addonPath of paths) { try { - console.log('Trying addon path:', addonPath); const addon = require(addonPath); nativeCapture = addon; - console.log('Native capture addon loaded from:', addonPath); return addon; } catch (e) { lastErr = e.message; - console.warn('Addon path failed:', addonPath, '-', lastErr); } } const errMsg = 'Addon not built: ' + lastErr; @@ -415,25 +412,20 @@ function startNativeCapture(win) { const addon = loadNativeCapture(win); if (!addon) { return; } if (addon._running) return; - console.log('native capture: starting...'); - let cbCount=0; try { addon.start( (buf, frames) => { - cbCount++; - if (cbCount%50===0) console.log('native capture: data cb #'+cbCount+' frames='+frames); if (win && !win.isDestroyed()) { - try { win.send('pair:cleanAudio', buf, frames); } catch(e) { console.warn('send cleanAudio err:', e.message); } + try { win.send('pair:cleanAudio', buf, frames); } catch {} } }, (errMsg) => { - console.warn('native capture err:', errMsg); + if (process.env.PAIR_DEBUG === '1') console.warn('native capture err:', errMsg); if (win && !win.isDestroyed()) win.send('pair:captureError', errMsg); } ); addon._running = true; const fmt = addon.getFormat(); - console.log('native capture: started, format=',JSON.stringify(fmt)); if (win && !win.isDestroyed()) win.send('pair:captureFormat', fmt); } catch(e) { console.warn('native capture start failed:', e.message); @@ -449,22 +441,18 @@ function stopNativeCapture() { } ipcMain.on('pair:startCapture', (event) => { if (!isPairRenderer(event)) return; - console.log('native capture: IPC startCapture'); startNativeCapture(event.sender); }); ipcMain.on('pair:stopCapture', event => { if (!isPairRenderer(event)) return; - console.log('native capture: IPC stopCapture'); stopNativeCapture(); }); -let refCount=0; ipcMain.on('pair:captureRef', (event, buf) => { if (!isPairRenderer(event) || !buf || buf.byteLength > MAX_IPC_CHUNK) return; - refCount++; - if(refCount%50===0)console.log('native capture: ref #'+refCount+' bytes='+(buf?.byteLength||buf?.length)); const addon = nativeCapture; - if (!addon || !addon._running) { if(refCount===1)console.warn('native capture: ref sent but addon not running'); return; } - // IPC delivers ArrayBuffer; addon expects a Node.js Buffer + if (!addon || !addon._running) return; const b = Buffer.from(buf); - try { addon.pushReference(b); } catch (e) { console.warn('pushReference error:', e); } + try { addon.pushReference(b); } catch (e) { + if (process.env.PAIR_DEBUG === '1') console.warn('pushReference error:', e); + } }); diff --git a/package.json b/package.json index 952fde9..e266e01 100644 --- a/package.json +++ b/package.json @@ -1 +1,76 @@ -{"name":"pair-p2p","version":"1.1.24","description":"Private two-person P2P chat and file transfer","main":"main.js","author":{"name":"Pair","email":"pair@example.com"},"homepage":"https://example.com/pair","license":"MIT","scripts":{"start":"electron .","signal":"node server.js","dist":"electron-builder --linux tar.gz AppImage","dist:win":"electron-builder --win nsis","dist:all":"electron-builder --win nsis --linux tar.gz AppImage","rebuild:addon":"node rebuild-addon.js","publish":"node publish-update.js"},"devDependencies":{"electron":"^43.2.0","electron-builder":"^26.0.12"},"dependencies":{"node-addon-api":"^8.5.0","ws":"^8.18.3"},"build":{"asar":true,"appId":"com.pair.p2p","productName":"Pair","files":["index.html","styles.css","app.js","main.js","preload.js","updater.js","linux-launcher.js","server.js","addon/build/Release/pair-capture.node","build/**/*"],"asarUnpack":["addon/build/Release/pair-capture.node"],"linux":{"target":["tar.gz","AppImage"],"category":"Utility"},"win":{"target":[{"target":"nsis","arch":["x64"]}],"artifactName":"Pair Setup ${version}.${ext}"},"nsis":{"oneClick":false,"allowToChangeInstallationDirectory":true,"runAfterFinish":"true"}},"allowScripts":{"electron@43.2.0":true}} \ No newline at end of file +{ + "name": "pair-p2p", + "version": "1.1.24", + "description": "Private two-person P2P chat and file transfer", + "main": "main.js", + "author": { + "name": "Pair", + "email": "pair@example.com" + }, + "homepage": "https://example.com/pair", + "license": "MIT", + "scripts": { + "start": "electron .", + "signal": "node server.js", + "dist": "electron-builder --linux tar.gz AppImage", + "dist:win": "electron-builder --win nsis", + "dist:all": "electron-builder --win nsis --linux tar.gz AppImage", + "rebuild:addon": "node rebuild-addon.js", + "publish": "node publish-update.js", + "test": "node --test test/*.test.js" + }, + "devDependencies": { + "electron": "^43.2.0", + "electron-builder": "^26.0.12" + }, + "dependencies": { + "node-addon-api": "^8.5.0", + "ws": "^8.18.3" + }, + "build": { + "asar": true, + "appId": "com.pair.p2p", + "productName": "Pair", + "files": [ + "index.html", + "styles.css", + "app.js", + "main.js", + "preload.js", + "updater.js", + "linux-launcher.js", + "server.js", + "addon/build/Release/pair-capture.node", + "build/**/*" + ], + "asarUnpack": [ + "addon/build/Release/pair-capture.node" + ], + "linux": { + "target": [ + "tar.gz", + "AppImage" + ], + "category": "Utility" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "artifactName": "Pair Setup ${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "runAfterFinish": "true" + } + }, + "allowScripts": { + "electron@43.2.0": true + } +} diff --git a/pair-helpers.js b/pair-helpers.js new file mode 100644 index 0000000..6f40229 --- /dev/null +++ b/pair-helpers.js @@ -0,0 +1,125 @@ +/* Shared non-UI helpers for Pair (renderer + Node tests). */ +(function (root) { + 'use strict'; + + const DEFAULT_ICE_SERVERS = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ]; + + const MAX_SIGNAL_SDP = 256 * 1024; + const MAX_SIGNAL_PUB = 8192; + const SIGNAL_KINDS = new Set([ + 'offer', 'answer', 'reneg-offer', 'reneg-answer' + ]); + + function mergeIceServers(defaults, custom) { + const base = Array.isArray(defaults) ? defaults.slice() : DEFAULT_ICE_SERVERS.slice(); + if (!Array.isArray(custom) || !custom.length) return base; + const seen = new Set(base.map(s => JSON.stringify(s))); + for (const item of custom) { + const key = JSON.stringify(item); + if (!seen.has(key)) { + seen.add(key); + base.push(item); + } + } + return base; + } + + function turnServersFromConfig(raw) { + try { + const config = typeof raw === 'string' ? JSON.parse(raw || '[]') : (raw || []); + if (!Array.isArray(config)) return []; + return config.slice(0, 8).flatMap(item => { + if (!item || typeof item !== 'object') return []; + const urls = (Array.isArray(item.urls) ? item.urls : [item.urls]) + .filter(url => typeof url === 'string' && /^(?:stun|turn|turns):[^\s]+$/i.test(url)); + if (!urls.length) return []; + const server = { urls }; + if (typeof item.username === 'string' && item.username.length <= 512) server.username = item.username; + if (typeof item.credential === 'string' && item.credential.length <= 1024) server.credential = item.credential; + return [server]; + }); + } catch { + return []; + } + } + + function validateSignalPayload(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + if (!SIGNAL_KINDS.has(payload.kind)) return null; + if (typeof payload.sdp !== 'string' || !payload.sdp || payload.sdp.length > MAX_SIGNAL_SDP) return null; + if (payload.pub != null) { + if (typeof payload.pub !== 'string' || payload.pub.length > MAX_SIGNAL_PUB) return null; + } + const out = { kind: payload.kind, sdp: payload.sdp }; + if (typeof payload.pub === 'string') out.pub = payload.pub; + return out; + } + + function patchOpusSdp(sdp, bitrate = 96000) { + const br = Math.max(16000, Math.min(256000, Number(bitrate) || 96000)); + return String(sdp).replace(/a=fmtp:111[^\r\n]*/g, m => { + if (!m.includes('maxaveragebitrate')) m += `; maxaveragebitrate=${br}`; + else m = m.replace(/maxaveragebitrate=\d+/, `maxaveragebitrate=${br}`); + if (!m.includes('maxplaybackrate')) m += '; maxplaybackrate=48000'; + if (!m.includes('useinbandfec')) m += '; useinbandfec=1'; + if (!m.includes('usedtx')) m += '; usedtx=1'; + else m = m.replace(/usedtx=[01]/, 'usedtx=1'); + if (!m.includes('stereo')) m += '; stereo=1'; + else m = m.replace(/stereo=[01]/, 'stereo=1'); + if (!m.includes('sprop-stereo')) m += '; sprop-stereo=1'; + else m = m.replace(/sprop-stereo=[01]/, 'sprop-stereo=1'); + return m; + }); + } + + function patchVideoSdp(sdp, maxBitrateKbps = 12000) { + const cap = Math.max(2000, Math.min(140000, Number(maxBitrateKbps) || 12000)); + sdp = String(sdp).replace(/\r\n/g, '\n'); + return sdp.replace(/^m=video .*\n(?:[^m].*\n)*/gm, m => { + let section = m; + section = section.replace(/\nb=AS:\d+/g, ''); + section = section.replace(/\na=x-google-(?:min|max)-bitrate:\d+/g, ''); + return section + `a=x-google-max-bitrate:${cap}\n`; + }); + } + + function preferredVideoCodecs(preferred) { + const order = preferred === 'auto' || !preferred + ? ['H264', 'VP9', 'VP8', 'H265', 'AV1'] + : [preferred, 'H264', 'VP9', 'VP8', 'H265', 'AV1']; + return [...new Set(order)]; + } + + function isDebugEnabled() { + try { + if (typeof process !== 'undefined' && process.env && process.env.PAIR_DEBUG === '1') return true; + } catch {} + try { + if (typeof localStorage !== 'undefined' && localStorage.getItem('PAIR_DEBUG') === '1') return true; + } catch {} + return false; + } + + function debugLog(...args) { + if (isDebugEnabled()) console.log('[pair]', ...args); + } + + const helpers = { + DEFAULT_ICE_SERVERS, + MAX_SIGNAL_SDP, + mergeIceServers, + turnServersFromConfig, + validateSignalPayload, + patchOpusSdp, + patchVideoSdp, + preferredVideoCodecs, + isDebugEnabled, + debugLog + }; + + if (typeof module !== 'undefined' && module.exports) module.exports = helpers; + root.pairHelpers = helpers; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/preload.js b/preload.js index 5b3fe18..4ae3c6a 100644 --- a/preload.js +++ b/preload.js @@ -1,22 +1,13 @@ const { contextBridge, ipcRenderer } = require('electron'); +const { turnServersFromConfig, mergeIceServers, DEFAULT_ICE_SERVERS } = require('./pair-helpers'); -function turnServersFromEnvironment() { - try { - const config = JSON.parse(process.env.PAIR_TURN || '[]'); - if (!Array.isArray(config)) return []; - return config.slice(0, 8).flatMap(item => { - if (!item || typeof item !== 'object') return []; - const urls = (Array.isArray(item.urls) ? item.urls : [item.urls]) - .filter(url => typeof url === 'string' && /^(?:stun|turn|turns):[^\s]+$/i.test(url)); - if (!urls.length) return []; - const server = { urls }; - if (typeof item.username === 'string' && item.username.length <= 512) server.username = item.username; - if (typeof item.credential === 'string' && item.credential.length <= 1024) server.credential = item.credential; - return [server]; - }); - } catch { - return []; - } +function iceServersForRenderer() { + return mergeIceServers(DEFAULT_ICE_SERVERS, turnServersFromConfig(process.env.PAIR_TURN || '[]')); +} + +function optionalKey(name) { + const value = process.env[name]; + return typeof value === 'string' && value.length > 8 && value.length < 256 ? value : ''; } // Minimal, audited bridge for streaming an incoming file to disk. @@ -54,7 +45,9 @@ contextBridge.exposeInMainWorld('pairUpdates', { contextBridge.exposeInMainWorld('pairEnv', { platform: process.platform, isApp: true, - iceServers: turnServersFromEnvironment(), + iceServers: iceServersForRenderer(), + giphyKey: optionalKey('PAIR_GIPHY_KEY'), + klipyKey: optionalKey('PAIR_KLIPY_KEY'), toggleFullscreen: () => ipcRenderer.send('pair:toggleFullscreen'), onFullscreenChange: cb => { if (typeof cb !== 'function') return () => {}; const listener = (_event, value) => cb(!!value); ipcRenderer.on('pair:fullscreenChanged', listener); return () => ipcRenderer.removeListener('pair:fullscreenChanged', listener); }, getSystemAvatar: () => ipcRenderer.invoke('pair:getSystemAvatar'), diff --git a/server.js b/server.js index 025c260..891e142 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,8 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); +const { validateSignalPayload } = require('./pair-helpers'); + const port = Number(process.env.PORT || 8787); const rooms = new Map(); @@ -75,7 +77,7 @@ function buildLandingPage(manifest) {

Pair — private P2P chat

-
Latest version: ${version}
+
Latest version: ${escapeHtml(version)}
${notes ? `
${escapeHtml(notes)}
` : ''} Download for Windows Download for Linux @@ -186,8 +188,16 @@ function leave(socket) { if (!socket.room) return; const peers = rooms.get(socket.room) || []; const remaining = peers.filter(peer => peer !== socket); - if (remaining.length) rooms.set(socket.room, remaining); - else rooms.delete(socket.room); + if (remaining.length) { + rooms.set(socket.room, remaining); + // Tell the remaining peer so they can tear down WebRTC cleanly instead of + // waiting for ICE to fail silently after the other side closes. + for (const peer of remaining) { + if (peer.readyState === WebSocket.OPEN) { + try { peer.send(JSON.stringify({ type: 'peer-left' })); } catch {} + } + } + } else rooms.delete(socket.room); socket.room = null; } @@ -229,9 +239,11 @@ wss.on('connection', socket => { return; } if (socket.room && message.type === 'signal') { - // Relay signaling + any other JSON control messages to the peer. + const payload = validateSignalPayload(message.payload); + if (!payload) return; + const outbound = JSON.stringify({ type: 'signal', payload }); for (const peer of rooms.get(socket.room) || []) { - if (peer !== socket && peer.readyState === WebSocket.OPEN) peer.send(JSON.stringify(message)); + if (peer !== socket && peer.readyState === WebSocket.OPEN) peer.send(outbound); } } }); diff --git a/share-audio-worklet.js b/share-audio-worklet.js new file mode 100644 index 0000000..579816d --- /dev/null +++ b/share-audio-worklet.js @@ -0,0 +1,49 @@ +/* AudioWorklet: consume interleaved stereo PCM for screen-share system audio. */ +class PairShareAudioProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.capacity = 96000; + this.buffer = new Float32Array(this.capacity * 2); + this.write = 0; + this.available = 0; + this.port.onmessage = (event) => { + const data = event.data; + if (!data || data.type !== 'pcm' || !(data.samples instanceof Float32Array)) return; + const samples = data.samples; + const frames = (samples.length / 2) | 0; + for (let i = 0; i < frames; i++) { + if (this.available >= this.capacity) { + this.available--; + } + const idx = this.write * 2; + this.buffer[idx] = samples[i * 2]; + this.buffer[idx + 1] = samples[i * 2 + 1]; + this.write = (this.write + 1) % this.capacity; + this.available++; + } + }; + } + + process(_inputs, outputs) { + const output = outputs[0]; + if (!output || output.length < 2) return true; + const L = output[0]; + const R = output[1]; + const n = L.length; + if (this.available < n) { + L.fill(0); + R.fill(0); + return true; + } + const read = (this.write - this.available + this.capacity) % this.capacity; + for (let i = 0; i < n; i++) { + const idx = ((read + i) % this.capacity) * 2; + L[i] = this.buffer[idx]; + R[i] = this.buffer[idx + 1]; + } + this.available -= n; + return true; + } +} + +registerProcessor('pair-share-audio', PairShareAudioProcessor); diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..0a679ce --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + mergeIceServers, + turnServersFromConfig, + validateSignalPayload, + patchOpusSdp, + patchVideoSdp, + preferredVideoCodecs, + DEFAULT_ICE_SERVERS +} = require('../pair-helpers'); + +test('mergeIceServers keeps STUN and appends TURN', () => { + const merged = mergeIceServers(DEFAULT_ICE_SERVERS, [ + { urls: 'turn:example.com:3478', username: 'u', credential: 'p' } + ]); + assert.equal(merged.length, 3); + assert.match(merged[0].urls, /^stun:/); + assert.equal(merged[2].urls, 'turn:example.com:3478'); +}); + +test('turnServersFromConfig rejects non-turn-like urls', () => { + const servers = turnServersFromConfig(JSON.stringify([ + { urls: 'http://evil.example' }, + { urls: 'turns:ok.example:443', username: 'a', credential: 'b' } + ])); + assert.equal(servers.length, 1); + assert.equal(servers[0].urls[0] || servers[0].urls, 'turns:ok.example:443'); +}); + +test('validateSignalPayload allowlists kind and bounds sdp', () => { + assert.equal(validateSignalPayload({ kind: 'hack', sdp: 'v=0' }), null); + assert.equal(validateSignalPayload({ kind: 'offer', sdp: '' }), null); + const ok = validateSignalPayload({ kind: 'reneg-answer', sdp: 'v=0\nm=audio', pub: 'abc' }); + assert.deepEqual(ok, { kind: 'reneg-answer', sdp: 'v=0\nm=audio', pub: 'abc' }); +}); + +test('patchOpusSdp sets bitrate and dtx', () => { + const sdp = 'a=fmtp:111 minptime=10;useinbandfec=1\r\n'; + const out = patchOpusSdp(sdp, 64000); + assert.match(out, /maxaveragebitrate=64000/); + assert.match(out, /usedtx=1/); +}); + +test('patchVideoSdp clamps google max bitrate', () => { + const sdp = 'm=video 9 UDP/TLS/RTP/SAVPF 96\na=rtpmap:96 H264/90000\n'; + const out = patchVideoSdp(sdp, 8000); + assert.match(out, /a=x-google-max-bitrate:8000/); +}); + +test('preferredVideoCodecs puts hardware-friendly codecs first', () => { + assert.deepEqual(preferredVideoCodecs('auto').slice(0, 3), ['H264', 'VP9', 'VP8']); + assert.equal(preferredVideoCodecs('auto').at(-1), 'AV1'); + assert.equal(preferredVideoCodecs('VP9')[0], 'VP9'); +}); diff --git a/test/lifecycle.test.js b/test/lifecycle.test.js new file mode 100644 index 0000000..6b93d3f --- /dev/null +++ b/test/lifecycle.test.js @@ -0,0 +1,87 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// Lightweight call/share generation guards — mirrors abort semantics used in app.js. +function makeShareSession() { + let screenGen = 0; + let screenActive = false; + let screenStarting = false; + let captureStopped = false; + return { + start() { + if (screenActive || screenStarting) return false; + screenStarting = true; + const gen = ++screenGen; + return { + gen, + activate() { + if (gen !== screenGen) return false; + screenActive = true; + screenStarting = false; + return true; + }, + abort() { + return gen !== screenGen; + } + }; + }, + stop() { + screenGen++; + screenStarting = false; + screenActive = false; + captureStopped = true; + }, + state: () => ({ screenGen, screenActive, screenStarting, captureStopped }) + }; +} + +test('share start/stop generation aborts stale attaches', () => { + const share = makeShareSession(); + const a = share.start(); + assert.equal(!!a, true); + assert.equal(a.activate(), true); + share.stop(); + assert.equal(a.abort(), true); + assert.equal(share.state().captureStopped, true); + assert.equal(share.state().screenActive, false); +}); + +test('linux share audio sink is discarded when generation advances mid-setup', () => { + let screenGen = 1; + let sinkAlive = false; + const aborted = (expectedGen) => expectedGen !== screenGen; + const discardSink = () => { sinkAlive = false; }; + // Simulate startLinuxShareAudio succeeding, then stopScreenShare mid-poll. + sinkAlive = true; + const expectedGen = 1; + screenGen = 2; + if (aborted(expectedGen)) discardSink(); + assert.equal(sinkAlive, false); +}); + +test('second share start is blocked while active', () => { + const share = makeShareSession(); + const a = share.start(); + a.activate(); + assert.equal(share.start(), false); +}); + +test('call starting guard prevents overlapping starts', () => { + let callStarting = false; + let callActive = false; + let starts = 0; + async function startCall() { + if (callActive || callStarting) return false; + callStarting = true; + starts++; + await Promise.resolve(); + callActive = true; + callStarting = false; + return true; + } + return Promise.all([startCall(), startCall()]).then(results => { + assert.deepEqual(results, [true, false]); + assert.equal(starts, 1); + assert.equal(callActive, true); + }); +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..cf751c5 --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const WebSocket = require('ws'); +const { validateSignalPayload } = require('../pair-helpers'); + +function waitOpen(ws) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('ws open timeout')), 5000); + ws.once('open', () => { clearTimeout(t); resolve(); }); + ws.once('error', err => { clearTimeout(t); reject(err); }); + }); +} + +function onceMessage(ws) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('ws message timeout')), 5000); + ws.once('message', data => { + clearTimeout(t); + try { resolve(JSON.parse(String(data))); } catch (e) { reject(e); } + }); + }); +} + +test('validRoom and signal validation stay strict', () => { + // Mirror server.js room rules without starting a listener. + const validRoom = value => { + const parts = String(value).split(':'); + const [base, suffix] = parts; + return parts.length <= 2 && /^[A-Z0-9_-]{16,64}$/.test(base) && (suffix === undefined || suffix.toLowerCase() === 'stream'); + }; + assert.equal(validRoom('ABCDEFGHIJKLMNOP'), true); + assert.equal(validRoom('short'), false); + assert.equal(validRoom('ABCDEFGHIJKLMNOP:stream'), true); + assert.equal(validateSignalPayload({ kind: 'offer', sdp: 'x'.repeat(300000) }), null); +}); + +test('signaling room capacity, peer-left, and signal allowlist', async () => { + const port = 18787 + Math.floor(Math.random() * 1000); + const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { + env: { ...process.env, PORT: String(port), PAIR_BIND: '127.0.0.1' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + await new Promise((resolve, reject) => { + const fail = setTimeout(() => reject(new Error('server start timeout')), 8000); + child.stdout.on('data', chunk => { + if (String(chunk).includes('listening')) { + clearTimeout(fail); + resolve(); + } + }); + child.stderr.on('data', () => {}); + child.on('exit', code => reject(new Error('server exited early ' + code))); + }); + + try { + const room = 'TESTROOMABCDEFGHIJ'; + const a = new WebSocket(`ws://127.0.0.1:${port}`); + const b = new WebSocket(`ws://127.0.0.1:${port}`); + await Promise.all([waitOpen(a), waitOpen(b)]); + + a.send(JSON.stringify({ type: 'join', room })); + assert.equal((await onceMessage(a)).type, 'joined'); + b.send(JSON.stringify({ type: 'join', room })); + const msgs = []; + msgs.push(await onceMessage(b)); + msgs.push(await onceMessage(a)); + msgs.push(await onceMessage(b)); + assert.ok(msgs.some(m => m.type === 'peer-ready')); + + const leftWait = onceMessage(a); + b.close(); + const left = await leftWait; + assert.equal(left.type, 'peer-left'); + + const c = new WebSocket(`ws://127.0.0.1:${port}`); + await waitOpen(c); + const aAfterJoin = onceMessage(a); + c.send(JSON.stringify({ type: 'join', room })); + assert.equal((await onceMessage(c)).type, 'joined'); + // Both peers receive peer-ready when the second client joins again. + assert.equal((await aAfterJoin).type, 'peer-ready'); + assert.equal((await onceMessage(c)).type, 'peer-ready'); + + a.send(JSON.stringify({ type: 'signal', payload: { kind: 'nope', sdp: 'x' } })); + a.send(JSON.stringify({ type: 'signal', payload: { kind: 'reneg-offer', sdp: 'v=0' } })); + const relayed = await onceMessage(c); + assert.equal(relayed.type, 'signal'); + assert.equal(relayed.payload.kind, 'reneg-offer'); + + a.close(); + c.close(); + } finally { + child.kill('SIGTERM'); + } +});