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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/macos-portable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: macOS portable packages

on:
pull_request:
paths:
- ".github/workflows/macos-portable.yml"
- "build/macos/**"
- "DexManager.AdbProxy/**"
- "DexManager.Core/**"
- "DexManager.Mac/**"
- "DexManager.MultiDeviceTests/**"
- "DexManager.Tests/**"
- "DexManager/licenses/**"
- "DexManager/config/README.txt"
- "Shared/**"
- "DexManager.Mac.sln"
- "LICENSE"
- "docs/MACOS_GUIDE.md"
- "docs/PACKAGE_README_MACOS.md"
- "global.json"
- "scripts/Package-Mac-Release.sh"
push:
branches:
- main
tags:
- "v*"
paths:
- ".github/workflows/macos-portable.yml"
- "build/macos/**"
- "DexManager.AdbProxy/**"
- "DexManager.Core/**"
- "DexManager.Mac/**"
- "DexManager.MultiDeviceTests/**"
- "DexManager.Tests/**"
- "DexManager/licenses/**"
- "DexManager/config/README.txt"
- "Shared/**"
- "DexManager.Mac.sln"
- "LICENSE"
- "docs/MACOS_GUIDE.md"
- "docs/PACKAGE_README_MACOS.md"
- "global.json"
- "scripts/Package-Mac-Release.sh"
workflow_dispatch:
inputs:
version:
description: Version used in the generated ZIP filenames
required: true
default: "2.0.1"

permissions:
contents: read

jobs:
build-portable:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Apple Silicon arm64
runner: macos-15
rid: osx-arm64
package_arch: arm64
- name: Intel x64
runner: macos-15-intel
rid: osx-x64
package_arch: x64

runs-on: ${{ matrix.runner }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Resolve package version
env:
REQUESTED_VERSION: ${{ inputs.version }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
if [[ "$REF_TYPE" == "tag" ]]; then
version="${REF_NAME#v}"
elif [[ -n "$REQUESTED_VERSION" ]]; then
version="$REQUESTED_VERSION"
else
version="2.0.1"
fi
echo "DXM_PACKAGE_VERSION=$version" >> "$GITHUB_ENV"

- name: Install pinned .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json

- name: Build with warnings treated as errors
run: dotnet build DexManager.Mac.sln --configuration Release --warnaserror --nologo

- name: Run unit and integration tests
run: dotnet test DexManager.Tests/DexManager.Tests.csproj --configuration Release --no-build --nologo

- name: Run multi-device foundation tests
run: dotnet run --project DexManager.MultiDeviceTests/DexManager.MultiDeviceTests.csproj --configuration Release --no-build

- name: Build and verify portable ZIP
run: >-
scripts/Package-Mac-Release.sh
--rid "${{ matrix.rid }}"
--version "$DXM_PACKAGE_VERSION"
--skip-tests

- name: Upload prebuilt portable package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: DX-Manager-v${{ env.DXM_PACKAGE_VERSION }}-macos-${{ matrix.package_arch }}
path: |
dist/DX-Manager-v${{ env.DXM_PACKAGE_VERSION }}-macos-${{ matrix.package_arch }}.zip
dist/DX-Manager-v${{ env.DXM_PACKAGE_VERSION }}-macos-${{ matrix.package_arch }}.zip.sha256
if-no-files-found: error
compression-level: 0
retention-days: 30

create-draft-release:
name: Create a verified draft GitHub Release
if: github.event_name == 'push' && github.ref_type == 'tag'
needs:
- build-portable
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download both verified packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: DX-Manager-v*-macos-*
path: release-assets
merge-multiple: true

- name: Verify the exact release asset set and checksums
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
version="${RELEASE_TAG#v}"
expected=(
"DX-Manager-v${version}-macos-arm64.zip"
"DX-Manager-v${version}-macos-arm64.zip.sha256"
"DX-Manager-v${version}-macos-x64.zip"
"DX-Manager-v${version}-macos-x64.zip.sha256"
)
mapfile -t actual < <(find release-assets -type f -print | sort)
if [[ "${#actual[@]}" -ne "${#expected[@]}" ]]; then
echo "Expected exactly four release files, found ${#actual[@]}." >&2
exit 1
fi
for filename in "${expected[@]}"; do
if [[ ! -f "release-assets/$filename" ]]; then
echo "Expected release file is missing: $filename" >&2
exit 1
fi
done
(
cd release-assets
sha256sum --check "DX-Manager-v${version}-macos-arm64.zip.sha256"
sha256sum --check "DX-Manager-v${version}-macos-x64.zip.sha256"
)

- name: Create draft release with verified packages
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
echo "A release already exists for $RELEASE_TAG; refusing to overwrite it." >&2
exit 1
fi
mapfile -t assets < <(find release-assets -type f -print | sort)
gh release create "$RELEASE_TAG" "${assets[@]}" \
--verify-tag \
--draft \
--generate-notes \
--title "DX Manager $RELEASE_TAG"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ dist/

# User local settings
**/config/settings.json
**/config/settings.json.*
**/*.dSYM/
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

## 작업 시작

1. `docs/README.md`, `docs/PROJECT_BRIEF.md`, `docs/SESSION.md`,
`docs/TODO.md`를 읽는다.
1. `docs/README.md`, `docs/HANDOFF.md`, `docs/PROJECT_BRIEF.md`,
`docs/SESSION.md`, `docs/TODO.md`를 읽는다.
2. Git 상태와 최근 커밋을 확인하고 사용자 변경 및 미커밋 파일을 보존한다.
3. 설계 이유가 필요한 작업은 `docs/DECISIONS.md`, 구현 세부 사항은
`docs/TECH_NOTES.md`, 알려진 제약은 `docs/KNOWN_ISSUES.md`를 확인한다.
Expand Down Expand Up @@ -39,8 +39,8 @@
적용한다. DX Manager 자체의 장치 관리, 무선 연결, wake-up과 화면 상태
명령 경로를 가로채지 않는다.
- Scrcpy 시작 직렬화와 프로세스 정리 규칙을 유지한다.
- v1 계열은 최초 선택한 물리 기기를 프로그램 종료까지 관리한다. 같은 기기의
USB/무선 transport 전환은 허용하지만 다른 물리 기기로 자동 전환하지 않는다.
- 현재 v2 계열은 여러 물리 기기를 동시에 관리한다. 모든 장치 명령과 정리 범위는
물리 identity별 런타임 및 작업 시작 시 캡처한 명시적 serial로 제한한다.
- display ID는 실제 설정과 디스플레이 목록의 전후 차이로 찾으며 모호한 ID를
추측하지 않는다.
- DeX overlay는 너비, 높이와 DPI가 모두 같을 때만 재사용한다. 하나라도 다르면
Expand All @@ -56,10 +56,10 @@
- 백그라운드 작업 결과로 UI를 갱신할 때 WinForms UI 스레드 규칙과 폼 종료
경합을 고려한다.

## 복수 휴대폰 선행 설계
## 복수 휴대폰 v2 불변 조건

- v1 계열의 사용자 동작은 한 대의 물리 기기 고정을 유지하되, 새 기능과 기존
기능의 구조 개선은 v2의 복수 휴대폰 동시 지원을 기본 전제로 설계한다.
- 공개 v2.0.0의 복수 휴대폰 동시 지원을 모든 새 기능과 구조 개선의 기본 전제로
유지한다. v1의 최초 기기 고정은 과거 버전 동작이며 현재 구조로 되돌리지 않는다.
- 장치별 동작에 전역 `TargetSerial`을 암묵적으로 사용하지 않는다. DeX,
단일창, 가상 디스플레이, Scrcpy 프로세스, 파일 전송과 정리 작업에는 대상
기기의 세션 또는 serial을 명시적으로 전달한다.
Expand Down
8 changes: 7 additions & 1 deletion DexManager.AdbProxy/DexManager.AdbProxy.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Title>DX Manager ADB Proxy</Title>
<Description>ADB compatibility helper for DX Manager</Description>
<Company>maze</Company>
<Product>DX Manager</Product>
<Copyright>Copyright © 2026 maze</Copyright>
<Version>2.0.0</Version>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
11 changes: 11 additions & 0 deletions DexManager.AdbProxy/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Reflection;
using DexManager.FileTransfer;

namespace DexManager.AdbProxy;
Expand All @@ -12,6 +13,16 @@ private static int Main(string[] args)
{
try
{
if (args.Length == 1 && args[0] == "--self-test")
{
var version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "unknown";
Console.WriteLine(
$"DX Manager ADB proxy {version} self-test passed.");
return 0;
}

var realAdbPath = Environment.GetEnvironmentVariable(
FileTransferEnvironment.RealAdbPath);
if (string.IsNullOrWhiteSpace(realAdbPath) ||
Expand Down
9 changes: 0 additions & 9 deletions DexManager.AdbProxy/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
using System.Reflection;
using System.Runtime.InteropServices;

[assembly: AssemblyTitle("DX Manager ADB Proxy")]
[assembly: AssemblyDescription("ADB compatibility helper for DX Manager")]
[assembly: AssemblyCompany("maze")]
[assembly: AssemblyProduct("DX Manager")]
[assembly: AssemblyCopyright("Copyright © 2026 maze")]
[assembly: ComVisible(false)]
[assembly: Guid("3a5433aa-c5ec-4741-89d7-a8a4934d5038")]
[assembly: AssemblyVersion("1.3.0.0")]
[assembly: AssemblyFileVersion("1.3.0.0")]
[assembly: AssemblyInformationalVersion("1.3.0")]
1 change: 1 addition & 0 deletions DexManager.Core/Models/ManagedDisplaySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public sealed class ManagedDisplaySession
{
public string Mode { get; set; }
public string Serial { get; set; }
public string DeviceIdentity { get; set; }
public string AppPackage { get; set; }
public int DisplayId { get; set; }
public int ScrcpyProcessId { get; set; }
Expand Down
18 changes: 18 additions & 0 deletions DexManager.Core/Services/DeviceMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public sealed class DeviceMonitorService : IDisposable
private readonly int _disconnectConfirmationMs;
private readonly object _stateLock = new object();
private readonly object _lifecycleLock = new object();
private readonly ManualResetEventSlim _firstPollCompleted =
new ManualResetEventSlim(false);
private readonly Dictionary<string, string> _deviceNameCache =
new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -78,6 +80,7 @@ public void Start()
if (_timer != null) return;
Interlocked.Exchange(ref _stopping, 0);
_missingSinceUtc = DateTime.MinValue;
_firstPollCompleted.Reset();
_timer = new Timer(Poll, null, 0, _intervalMs);
_logService.Info(LocalizationService.Get(
"Log.DeviceMonitor.Started"));
Expand All @@ -97,11 +100,21 @@ public void RequestShutdown()
lock (_lifecycleLock)
{
Interlocked.Exchange(ref _stopping, 1);
_firstPollCompleted.Set();
if (_timer != null)
_timer.Change(Timeout.Infinite, Timeout.Infinite);
}
}

public bool WaitForFirstPoll(
int timeoutMs,
CancellationToken cancellationToken)
{
return _firstPollCompleted.Wait(
Math.Max(0, timeoutMs),
cancellationToken);
}

public void Dispose()
{
lock (_lifecycleLock)
Expand All @@ -114,6 +127,7 @@ public void Dispose()
private void StopTimer()
{
Interlocked.Exchange(ref _stopping, 1);
_firstPollCompleted.Set();
var timer = Interlocked.Exchange(ref _timer, null);
if (timer == null) return;

Expand All @@ -130,6 +144,7 @@ private void Poll(object state)
if (IsStopping ||
Interlocked.Exchange(ref _polling, 1) == 1) return;

var snapshotCompleted = false;
try
{
IList<AdbDeviceInfo> devices;
Expand All @@ -141,6 +156,7 @@ private void Poll(object state)

RefreshVisibleDevices(devices);
ReconcilePhysicalDevices(devices);
snapshotCompleted = true;
var eligibleDevices = GetEligibleDevices(devices);
var preferred = _wirelessAdbService.FindPreferredDevice(
eligibleDevices,
Expand Down Expand Up @@ -219,6 +235,8 @@ private void Poll(object state)
finally
{
Interlocked.Exchange(ref _polling, 0);
if (snapshotCompleted)
_firstPollCompleted.Set();
}
}

Expand Down
Loading