From a05e9510515134f553bac0f5bfabeaa4edda1100 Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 17:36:31 +0200 Subject: [PATCH] Two native windows, and an installer that says something The Windows front end is WinUI 3 and the macOS one is SwiftUI, and the Tk window that stood in for both is gone. It was the thing the installer actually installed, so people who installed MThread Draw on Windows got the old interface, slowly, and never saw the native one at all. The installer now has a user interface. Without one an MSI shows "gathering required information", installs, and closes without a word, which is indistinguishable from failing - and it was read that way. Welcome, install location, progress, finished, and an offer to start the program. It carries the WinUI publish, so what gets installed is the native window with the engine folded into it. Everything is built in a scratch directory outside the checkout now. This tree lives in OneDrive on at least one machine and the sync client holds handles on files while it uploads them: writing several thousand of them into a synced folder failed at a different step every time, always with an access denied that named a file rather than the cause. Copying four finished artifacts in at the end does not collide. Two notes for whoever touches the .wxs next. A double dash is illegal inside an XML comment, so prose there cannot name a command line flag - WiX refuses the whole file with WIX0104. And the finish dialog starts the program with a plain exe action rather than the utility extension's shell helper, which needs the name of a binary only the extension knows. The Swift front end has not been compiled anywhere yet. There is no Mac here, and the macOS release job is the first thing that will build it. --- .github/workflows/release.yml | 23 +- README.md | 7 +- TERMS.md | 4 +- docs/RELEASING.md | 4 +- macos/Package.swift | 22 + macos/Sources/MThreadDraw/ContentView.swift | 438 ++++++++++++ macos/Sources/MThreadDraw/Engine.swift | 213 ++++++ macos/Sources/MThreadDraw/Glass.swift | 69 ++ macos/Sources/MThreadDraw/main.swift | 46 ++ mthread_draw/app.py | 697 -------------------- packaging/MThreadDraw.spec | 101 +-- packaging/MThreadDraw.wxs | 83 ++- pyproject.toml | 6 +- requirements.txt | 4 +- tests/test_gui.py | 26 - tools/build_app.py | 222 ++++--- tools/build_macos.py | 175 +++++ tools/pyinstaller_entry.py | 84 --- 18 files changed, 1222 insertions(+), 1002 deletions(-) create mode 100644 macos/Package.swift create mode 100644 macos/Sources/MThreadDraw/ContentView.swift create mode 100644 macos/Sources/MThreadDraw/Engine.swift create mode 100644 macos/Sources/MThreadDraw/Glass.swift create mode 100644 macos/Sources/MThreadDraw/main.swift delete mode 100644 mthread_draw/app.py delete mode 100644 tests/test_gui.py create mode 100644 tools/build_macos.py delete mode 100644 tools/pyinstaller_entry.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c79d34..251f6a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,8 @@ name: Release # # git tag v1.1.0 && git push origin v1.1.0 # -# which builds a Windows .msi, macOS .dmg for both architectures, the sdist and +# which builds the Windows installer, a macOS app and disk image for both +# architectures, the sdist and # the wheel, and attaches them all to a GitHub release. Each packaged build runs # its own self-test first, so an incomplete bundle fails here rather than in # somebody's dialog box. @@ -25,7 +26,7 @@ on: - "packaging/**" - "tools/build_app.py" - "tools/fetch_platform_tools.py" - - "tools/pyinstaller_entry.py" + - "tools/build_macos.py" - "tools/make_icon.py" - ".github/workflows/release.yml" workflow_dispatch: @@ -59,11 +60,11 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[gui]" pyinstaller + python -m pip install -e ".[draw]" pyinstaller dotnet tool install --global wix --version ${{ env.WIX_VERSION }} - - name: Build app, installer, WinUI front end and archive - run: python tools/build_app.py --msi --winui --archive + - name: Build the engine, the front end and the installer + run: python tools/build_app.py --msi - uses: actions/upload-artifact@v4 with: @@ -97,10 +98,13 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[gui]" pyinstaller + python -m pip install -e ".[draw]" pyinstaller - - name: Build app and disk image - run: python tools/build_app.py --dmg + - name: Build the engine + run: python tools/build_app.py + + - name: Build the front end and its disk image + run: python tools/build_macos.py --dmg - uses: actions/upload-artifact@v4 with: @@ -149,8 +153,7 @@ jobs: | | | |---|---| - | **Windows** | `MThreadDraw-*-x64.msi` — installs like any other program, Start Menu entry included. `MThreadDraw-*-windows-x64.zip` is the same app with no installer. | - | **Windows, native interface** | `MThreadDraw-WinUI-*-x64.zip` — the WinUI 3 front end. Unzip anywhere and run `MThreadDraw.exe`; it needs no runtime installed and carries its own copy of the engine. Larger, and the newer of the two. | + | **Windows** | `MThreadDraw-*-x64.msi` — installs the native WinUI 3 application, Start Menu entry included. `MThreadDraw-WinUI-*-x64.zip` is the same application with no installer: unzip anywhere and run `MThreadDraw.exe`. | | **macOS** | `MThreadDraw-*-arm64.dmg` for Apple Silicon, `MThreadDraw-*-x64.dmg` for Intel. Drag it to Applications. | | **Linux** | Run from source — see the [README](https://github.com/MAXAWER/MThread-Draw#install-variants). | diff --git a/README.md b/README.md index 099b4c3..502cab8 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,11 @@ most people twenty. |---|---| | **Windows** | [**Download the installer**](https://github.com/MAXAWER/MThread-Draw/releases/latest) — `MThreadDraw-x.y.z-x64.msi`. Installs like any other program, Start Menu entry and uninstaller included. | | **macOS** | [**Download the app**](https://github.com/MAXAWER/MThread-Draw/releases/latest) — `.dmg` for Apple Silicon or Intel. Drag it to Applications. | -| **Linux** | Run from source; three commands, [below](#from-source). | +| **Linux** | The command line, from source; three commands, [below](#from-source). | + +Both windows are native: WinUI 3 on Windows, SwiftUI on macOS, and each drives +the same engine over a pipe. Linux has the command line and the library, which +is everything except a window. **Nothing else to install.** Python, OpenCV and **adb** all travel inside the application — no Android SDK, no platform-tools download, no `PATH` to edit. @@ -447,6 +451,7 @@ explained in plain English and Russian in **[TERMS.md](TERMS.md)**. |---|---| | **Windows** | [**Скачать установщик**](https://github.com/MAXAWER/MThread-Draw/releases/latest) — `MThreadDraw-x.y.z-x64.msi`. Ставится как обычная программа, с ярлыком в меню «Пуск» и деинсталлятором. | | **macOS** | [**Скачать приложение**](https://github.com/MAXAWER/MThread-Draw/releases/latest) — `.dmg` для Apple Silicon или Intel, перетащить в Applications. | +| **Linux** | Командная строка из исходников. Окна для Linux нет: оба интерфейса нативные — WinUI 3 и SwiftUI. | | **Linux** | Из исходников, три команды — [ниже](#из-исходников). | **Больше ничего ставить не нужно.** Python, OpenCV и **adb** лежат внутри самого diff --git a/TERMS.md b/TERMS.md index a24dcc1..6ed3619 100644 --- a/TERMS.md +++ b/TERMS.md @@ -71,7 +71,7 @@ of the code. The packaged builds include Google's Android platform-tools (`adb`) under its own terms, with its `NOTICE.txt` alongside; Python, OpenCV, NumPy, Pillow and -customtkinter keep their own licences. Those are aggregated with this software, +Pillow keep their own licences. Those are aggregated with this software, not part of it. ### No warranty @@ -123,7 +123,7 @@ Open Source Initiative. На практике это значит: ### Сторонние компоненты В собранных версиях лежит `adb` от Google на своих условиях, вместе с его -`NOTICE.txt`; Python, OpenCV, NumPy, Pillow и customtkinter остаются под своими +`NOTICE.txt`; Python, OpenCV, NumPy и Pillow остаются под своими лицензиями. Они соседствуют с этой программой, но её частью не являются. ### Гарантий нет diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b836a80..6208008 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -17,8 +17,8 @@ | Artifact | Built on | |---|---| | `MThread Draw--x64.msi`, and the same app as a `.zip` | windows-latest | -| `MThread Draw--arm64.dmg` | macos-latest | -| `MThread Draw--x64.dmg` | macos-13 | +| `MThreadDraw--arm64.dmg` | macos-latest | +| `MThreadDraw--x64.dmg` | macos-15-intel | | sdist and wheel | ubuntu-latest | Tags must start with `v`. Anything else is ignored by the workflow. The same diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 0000000..5338113 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +// +// The macOS front end. A window and nothing more: tracing, ADB and touch +// injection all live in the Python engine, which Windows uses too, and which +// this launches as a child process and speaks JSON lines to over a pipe. +// +// A Swift package rather than an Xcode project on purpose - it builds with +// `swift build` on a CI runner with no Xcode project file to keep in sync, and +// tools/build_macos.py assembles the .app bundle around the binary afterwards. +import PackageDescription + +let package = Package( + name: "MThreadDraw", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "MThreadDraw", + path: "Sources/MThreadDraw", + swiftSettings: [.unsafeFlags(["-parse-as-library"])] + ) + ] +) diff --git a/macos/Sources/MThreadDraw/ContentView.swift b/macos/Sources/MThreadDraw/ContentView.swift new file mode 100644 index 0000000..d9a0fb9 --- /dev/null +++ b/macos/Sources/MThreadDraw/ContentView.swift @@ -0,0 +1,438 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +@MainActor +final class Model: ObservableObject { + let engine = Engine() + + @Published var devices: [(serial: String, description: String, ready: Bool)] = [] + @Published var selected: String? + @Published var connected = false + @Published var deviceLabel = "Not connected" + @Published var status = "Starting the engine…" + + @Published var layers: [(name: String, strokes: Int, erased: Int, visible: Bool)] = [] + @Published var current = 0 + @Published var totalStrokes = 0 + @Published var totalPoints = 0 + + @Published var frame: NSImage? + @Published var overlay: NSImage? + @Published var screen = CGSize(width: 1080, height: 2280) + + @Published var detail = 7.0 + @Published var feel = 0.0 + @Published var tracer = "canny" + @Published var estimate = "" + @Published var progress = 0.0 + @Published var drawing = false + @Published var erasing = false + + /// 0 draws as fast as it goes; 10 draws the way a hand would. + var speed: Double { 8.0 * pow(0.075, feel / 10.0) } + var human: Double { max(0, (feel - 4.0) / 3.0) } + + var hasImage: Bool { !layers.isEmpty } + + func begin() async { + engine.onStatus = { [weak self] text in Task { @MainActor in self?.status = text } } + engine.onProgress = { [weak self] done, total in + Task { @MainActor in self?.progress = total > 0 ? Double(done) / Double(total) : 0 } + } + engine.onFrame = { [weak self] path, width, height in + Task { @MainActor in self?.showFrame(path, width, height) } + } + engine.onMirrorLost = { [weak self] reason in + Task { @MainActor in self?.status = "The live view stopped: \(reason)" } + } + + do { + try await engine.start() + status = "Ready." + await refreshDevices() + } catch { + status = error.localizedDescription + } + } + + func refreshDevices() async { + do { + let result = try await engine.call("devices") + let list = result["devices"] as? [[String: Any]] ?? [] + devices = list.map { + ($0["serial"] as? String ?? "?", + $0["description"] as? String ?? "", + ($0["state"] as? String) == "device") + } + selected = devices.first?.serial + + if devices.count == 1, devices[0].ready, !connected { + // One device is not a choice, and the live view is the reason + // the window is worth looking at. + await connect() + } else if devices.isEmpty { + status = "No device, and restarting adb did not find one. " + + "Check the cable, turn on USB debugging, and accept the prompt on the phone." + } else { + status = "\(devices.count) device(s) found." + } + } catch { + status = error.localizedDescription + } + } + + func connect() async { + do { + var arguments: [String: Any] = [:] + if let selected { arguments["serial"] = selected } + let result = try await engine.call("connect", arguments) + + let width = result["width"] as? Int ?? 1080 + let height = result["height"] as? Int ?? 2280 + screen = CGSize(width: width, height: height) + connected = true + deviceLabel = "\(result["serial"] as? String ?? "?") · \(width)×\(height)" + status = (result["raw_touch"] as? Bool ?? false) + ? "Connected. This device allows raw touch events, which is the fast path." + : "Connected. This device refuses raw touch events, so drawing goes through the injector." + + _ = try await engine.call("mirror", ["on": true]) + await refreshEstimate() + } catch { + status = error.localizedDescription + } + } + + private func showFrame(_ path: String, _ width: Int, _ height: Int) { + if width > 0, height > 0 { + screen = CGSize(width: width, height: height) + } + // Read the bytes rather than pointing an image at the file: the engine + // is writing the next frame while this one is on screen. + guard let data = FileManager.default.contents(atPath: path) else { return } + frame = NSImage(data: data) + } + + private func apply(_ result: [String: Any]) { + let list = result["layers"] as? [[String: Any]] ?? [] + layers = list.map { + ($0["name"] as? String ?? "?", + $0["strokes"] as? Int ?? 0, + $0["erased"] as? Int ?? 0, + $0["visible"] as? Bool ?? true) + } + current = result["current"] as? Int ?? 0 + totalStrokes = result["strokes"] as? Int ?? 0 + totalPoints = result["points"] as? Int ?? 0 + if let path = result["overlay"] as? String, + let data = FileManager.default.contents(atPath: path) { + overlay = NSImage(data: data) + } else if layers.isEmpty { + overlay = nil + } + } + + func run(_ operation: String, _ arguments: [String: Any] = [:]) async { + do { + apply(try await engine.call(operation, arguments)) + await refreshEstimate() + } catch { + status = error.localizedDescription + } + } + + func loadImage() async { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .bmp, .webP] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + await run("load_image", ["path": url.path]) + } + + func preview() async { + await run("preview", ["sensitivity": detail, "detail": detail, "method": tracer]) + } + + func refreshEstimate() async { + guard connected, hasImage else { return } + do { + let result = try await engine.call("estimate", ["speed": speed, "human": human]) + let seconds = result["seconds"] as? Double ?? 0 + estimate = seconds >= 60 + ? String(format: "About %d min %d s to draw.", Int(seconds) / 60, Int(seconds) % 60) + : String(format: "About %.0f s to draw.", seconds) + } catch { + estimate = "" + } + } + + func draw() async { + drawing = true + defer { drawing = false; progress = 0 } + do { + let result = try await engine.call("draw", ["speed": speed, "human": human]) + status = (result["stopped"] as? Bool ?? false) + ? "Stopped." + : "Finished. \(result["strokes"] as? Int ?? 0) strokes drawn." + } catch { + status = error.localizedDescription + } + } + + func stop() { engine.post("stop") } +} + +struct ContentView: View { + @StateObject private var model = Model() + + var body: some View { + HStack(spacing: 0) { + controls + .frame(width: 320) + Divider().opacity(0.25) + phone + .frame(minWidth: 420) + } + .background(Glass()) + .safeAreaInset(edge: .bottom) { statusBar } + .task { await model.begin() } + } + + // MARK: - the left column + + private var controls: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + deviceRow + + GlassCard { + VStack(alignment: .leading, spacing: 10) { + Button("Load image…") { Task { await model.loadImage() } } + .frame(maxWidth: .infinity) + Text(model.hasImage + ? "\(model.layers.count) layer(s) · \(model.totalStrokes) strokes, \(model.totalPoints) points" + : "No image loaded") + .font(.caption).foregroundStyle(.secondary) + + if model.hasImage { layerList } + } + } + + GlassCard { + VStack(alignment: .leading, spacing: 12) { + Text("What is in the picture").font(.headline) + Picker("", selection: $model.tracer) { + Text("Buildings, machines, objects").tag("canny") + Text("Portraits, animals, nature").tag("flow") + } + .labelsHidden() + .onChange(of: model.tracer) { Task { await model.preview() } } + + slider("How much detail", value: $model.detail, range: 1...10, + caption: detailWord(model.detail)) { + Task { await model.preview() } + } + slider("How it draws", value: $model.feel, range: 0...10, + caption: feelWord(model.feel)) { + Task { await model.refreshEstimate() } + } + + if !model.estimate.isEmpty { + Text(model.estimate).font(.callout).foregroundStyle(.secondary) + } + } + } + + Button("START DRAWING") { Task { await model.draw() } } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + .disabled(!model.connected || !model.hasImage || model.drawing) + + Button("Stop") { model.stop() } + .frame(maxWidth: .infinity) + .disabled(!model.drawing) + } + .padding(18) + } + } + + private var deviceRow: some View { + GlassCard(padding: 14) { + VStack(alignment: .leading, spacing: 8) { + Picker("", selection: Binding( + get: { model.selected ?? "" }, + set: { model.selected = $0 })) { + if model.devices.isEmpty { + Text("Looking for a device…").tag("") + } + ForEach(model.devices, id: \.serial) { device in + Text("\(device.serial) — \(device.description)").tag(device.serial) + } + } + .labelsHidden() + + HStack { + Button("Refresh") { Task { await model.refreshDevices() } } + Button("Connect") { Task { await model.connect() } } + .buttonStyle(.borderedProminent) + } + Text(model.deviceLabel).font(.caption).foregroundStyle(.secondary) + } + } + } + + private var layerList: some View { + VStack(spacing: 6) { + ForEach(Array(model.layers.enumerated()), id: \.offset) { index, layer in + HStack { + Text(layer.name).lineLimit(1) + Spacer() + Text("\(layer.strokes)").foregroundStyle(.secondary).font(.caption) + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(index == model.current ? .white.opacity(0.14) : .clear) + } + .opacity(layer.visible ? 1 : 0.45) + .onTapGesture { Task { await model.run("layer_select", ["index": index]) } } + } + + HStack(spacing: 6) { + Button("Forward") { Task { await model.run("layer_raise") } } + Button(model.layers.indices.contains(model.current) + && model.layers[model.current].visible ? "Hide" : "Show") { + let visible = model.layers.indices.contains(model.current) + ? model.layers[model.current].visible : true + Task { await model.run("layer_visible", ["visible": !visible]) } + } + Button("Remove") { Task { await model.run("layer_remove") } } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func slider(_ title: String, value: Binding, + range: ClosedRange, caption: String, + onChange: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + Slider(value: value, in: range, step: 1) { editing in + if !editing { onChange() } + } + Text(caption).font(.caption).foregroundStyle(.secondary) + } + } + + // MARK: - the phone + + private var phone: some View { + VStack(spacing: 12) { + GeometryReader { room in + let side = fit(in: room.size) + ZStack { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(.black.opacity(0.55)) + if let frame = model.frame { + Image(nsImage: frame) + .resizable() + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .padding(9) + } + if let overlay = model.overlay { + Image(nsImage: overlay) + .resizable() + .padding(9) + } + if model.frame == nil && model.overlay == nil { + Text("Connect a device to see its screen here.") + .foregroundStyle(.secondary) + } + } + .frame(width: side.width, height: side.height) + .position(x: room.size.width / 2, y: room.size.height / 2) + .gesture(placement(side: side)) + } + + HStack(spacing: 8) { + Toggle("Erase", isOn: $model.erasing).toggleStyle(.button) + Button("Undo erase") { Task { await model.run("erase", ["undo": true]) } } + Button("Flip ↔") { Task { await model.run("place", ["flip_x": true]) } } + Button("Flip ↕") { Task { await model.run("place", ["flip_y": true]) } } + Button("Fit") { Task { await model.run("place", ["reset": true]) } } + } + .controlSize(.small) + .disabled(!model.hasImage) + + Text(model.erasing + ? "Drag across strokes to take them out" + : "Drag to move it · scroll to resize · Shift and scroll to turn") + .font(.caption).foregroundStyle(.secondary) + } + .padding(18) + } + + /// The phone drawn at the proportions of the real screen. + private func fit(in room: CGSize) -> CGSize { + let ratio = model.screen.width / max(model.screen.height, 1) + let byHeight = CGSize(width: (room.height - 20) * ratio, height: room.height - 20) + if byHeight.width <= room.width - 20 { return byHeight } + return CGSize(width: room.width - 20, height: (room.width - 20) / ratio) + } + + private func placement(side: CGSize) -> some Gesture { + DragGesture(minimumDistance: 1) + .onChanged { value in + guard model.hasImage, !model.drawing else { return } + if model.erasing { + let x = value.location.x / side.width + let y = value.location.y / side.height + guard (0...1).contains(x), (0...1).contains(y) else { return } + Task { await model.run("erase", ["x": x, "y": y, "radius": 0.025]) } + } else { + // In fractions of the phone's own picture, so a drag moves + // the drawing the same share of the screen at any size. + let dx = value.translation.width / side.width + let dy = value.translation.height / side.height + Task { await model.run("place", ["dx": dx, "dy": dy]) } + } + } + } + + private var statusBar: some View { + HStack { + Text(model.status).lineLimit(2) + Spacer() + if model.drawing { + ProgressView(value: model.progress).frame(width: 180) + } + } + .font(.callout) + .padding(.horizontal, 18).padding(.vertical, 10) + .background(.ultraThinMaterial) + } + + private func detailWord(_ value: Double) -> String { + switch value { + case ..<3: return "\(Int(value)) — the shape and little else" + case ..<5: return "\(Int(value)) — the main lines" + case ..<7: return "\(Int(value)) — a moderate amount" + case ..<9: return "\(Int(value)) — a fair amount" + default: return "\(Int(value)) — everything it can find" + } + } + + private func feelWord(_ value: Double) -> String { + switch value { + case 0: return "instantly" + case ..<3: return String(format: "very fast — %.1fx", model.speed) + case ..<5: return String(format: "fast — %.1fx", model.speed) + case ..<7: return String(format: "like a quick hand — %.1fx", model.speed) + case ..<9: return String(format: "like a hand — %.1fx", model.speed) + default: return String(format: "like a careful hand — %.1fx", model.speed) + } + } +} diff --git a/macos/Sources/MThreadDraw/Engine.swift b/macos/Sources/MThreadDraw/Engine.swift new file mode 100644 index 0000000..444ac77 --- /dev/null +++ b/macos/Sources/MThreadDraw/Engine.swift @@ -0,0 +1,213 @@ +import Foundation + +/// The MThread Draw engine, running as a child process and spoken to over a pipe. +/// +/// Everything interesting - tracing an image, finding a device, injecting +/// touches - lives in the Python engine, which the Windows front end uses too. +/// This class is the whole of the Mac side's knowledge of it: write a JSON line, +/// wait for the reply carrying the same id, and publish anything that arrives +/// unasked. +/// +/// Porting the engine to Swift would mean a second implementation of the same +/// subtleties, and they would drift apart by the second release. +final class Engine { + + enum EngineError: LocalizedError { + case notStarted(String) + case refused(String) + + var errorDescription: String? { + switch self { + case .notStarted(let detail): return "The engine did not start.\n\(detail)" + case .refused(let detail): return detail + } + } + } + + private let process = Process() + private let input = Pipe() + private let output = Pipe() + private var nextId = 0 + private var pending: [Int: CheckedContinuation<[String: Any], Error>] = [:] + private let lock = NSLock() + private var buffer = Data() + + /// Called for events - anything the engine sends without being asked. + var onStatus: ((String) -> Void)? + var onProgress: ((Int, Int) -> Void)? + var onFrame: ((String, Int, Int) -> Void)? + var onMirrorLost: ((String) -> Void)? + + private var readyContinuation: CheckedContinuation? + + /// Where the engine is, in a release and in a source checkout. + static func locate() -> (URL, [String]) { + let bundle = Bundle.main.bundleURL + + // A release ships the engine inside the bundle, beside the binary. + let packaged = bundle + .appendingPathComponent("Contents/Resources/engine/mthread-draw-engine") + if FileManager.default.isExecutableFile(atPath: packaged.path) { + return (packaged, []) + } + + // A source checkout runs it from the repository's virtual environment, + // and failing that from whatever python is on PATH. + if let repository = findRepository() { + let venv = repository.appendingPathComponent("venv/bin/python") + let python = FileManager.default.isExecutableFile(atPath: venv.path) + ? venv + : URL(fileURLWithPath: "/usr/bin/env") + let arguments = python.lastPathComponent == "env" + ? ["python3", "-m", "mthread_draw.server"] + : ["-m", "mthread_draw.server"] + return (python, arguments) + } + return (URL(fileURLWithPath: "/usr/bin/env"), + ["python3", "-m", "mthread_draw.server"]) + } + + /// Walk up from the binary looking for the engine's own source. + /// + /// Counting "../.." from the build directory works until somebody changes + /// the configuration, at which point it silently points at the wrong folder. + /// A marker file does not have that problem. + static func findRepository() -> URL? { + var directory = Bundle.main.bundleURL + for _ in 0..<8 { + let marker = directory.appendingPathComponent("mthread_draw/server.py") + if FileManager.default.fileExists(atPath: marker.path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + return nil + } + + func start() async throws { + let (executable, arguments) = Engine.locate() + process.executableURL = executable + process.arguments = arguments + process.standardInput = input + process.standardOutput = output + process.standardError = Pipe() + if let repository = Engine.findRepository() { + process.currentDirectoryURL = repository + } + + output.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.absorb(handle.availableData) + } + + try process.run() + + // The engine announces itself; if that never arrives, nothing else will. + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [weak self] in + try await withCheckedThrowingContinuation { continuation in + self?.readyContinuation = continuation + } + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw EngineError.notStarted("It produced no output in thirty seconds.") + } + try await group.next() + group.cancelAll() + } + } + + // MARK: - reading + + private func absorb(_ data: Data) { + guard !data.isEmpty else { return } + buffer.append(data) + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer[buffer.startIndex.. [String: Any] { + lock.lock() + nextId += 1 + let id = nextId + lock.unlock() + + var request: [String: Any] = arguments + request["id"] = id + request["op"] = operation + let line = try JSONSerialization.data(withJSONObject: request) + Data([0x0A]) + + return try await withCheckedThrowingContinuation { continuation in + lock.lock() + pending[id] = continuation + lock.unlock() + input.fileHandleForWriting.write(line) + } + } + + /// Send a request without waiting - for stop, which cannot queue. + func post(_ operation: String) { + let line = "{\"op\": \"\(operation)\"}\n" + input.fileHandleForWriting.write(Data(line.utf8)) + } + + func shutDown() { + post("quit") + // A dead engine is the desired outcome either way. + DispatchQueue.global().asyncAfter(deadline: .now() + 3) { [process] in + if process.isRunning { process.terminate() } + } + } +} diff --git a/macos/Sources/MThreadDraw/Glass.swift b/macos/Sources/MThreadDraw/Glass.swift new file mode 100644 index 0000000..6299e16 --- /dev/null +++ b/macos/Sources/MThreadDraw/Glass.swift @@ -0,0 +1,69 @@ +import AppKit +import SwiftUI + +/// The frosted material the whole window sits on. +/// +/// SwiftUI has `.background(.ultraThinMaterial)`, and it is not the same thing: +/// that frosts what is behind it *inside* the window. `NSVisualEffectView` with +/// `behindWindow` blending samples the desktop and whatever is under the window, +/// which is what makes a window look like glass rather than like a grey panel. +struct Glass: NSViewRepresentable { + var material: NSVisualEffectView.Material = .underWindowBackground + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = .behindWindow + // Frosted whether or not the window has focus; the alternative is a + // window that turns opaque the moment you click somewhere else. + view.state = .active + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.material = material + } +} + +/// A card that reads as a pane of glass laid on the window's own frost. +struct GlassCard: View { + var padding: CGFloat = 18 + @ViewBuilder var content: Content + + var body: some View { + content + .padding(padding) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.white.opacity(0.06)) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + } + .overlay { + // A hairline that catches the light along the top edge, + // which is most of what makes glass look like glass. + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [.white.opacity(0.22), .white.opacity(0.05)], + startPoint: .top, endPoint: .bottom), + lineWidth: 1) + } + } + } +} + +extension NSWindow { + /// Make the window itself transparent enough for the frost to show. + func makeGlassy() { + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovableByWindowBackground = true + // The frost comes from the visual effect view; an opaque window would + // paint over it before the view ever drew. + isOpaque = false + backgroundColor = .clear + styleMask.insert(.fullSizeContentView) + } +} diff --git a/macos/Sources/MThreadDraw/main.swift b/macos/Sources/MThreadDraw/main.swift new file mode 100644 index 0000000..cd8b931 --- /dev/null +++ b/macos/Sources/MThreadDraw/main.swift @@ -0,0 +1,46 @@ +import AppKit +import SwiftUI + +/// The application, assembled by hand rather than by an Xcode template. +/// +/// A Swift package builds with `swift build` on a runner with no Xcode project +/// to keep in sync, and tools/build_macos.py wraps the binary in a bundle. The +/// cost is that the window has to be created here instead of being declared, +/// which is worth it for one window. +@main +struct MThreadDrawApp { + static func main() { + let application = NSApplication.shared + let delegate = Delegate() + application.delegate = delegate + application.setActivationPolicy(.regular) + application.run() + } +} + +final class Delegate: NSObject, NSApplicationDelegate { + private var window: NSWindow? + + func applicationDidFinishLaunching(_ notification: Notification) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1040, height: 760), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + + window.title = "MThread Draw" + window.makeGlassy() + window.contentView = NSHostingView(rootView: ContentView()) + window.setFrameAutosaveName("MThreadDraw") + window.minSize = NSSize(width: 820, height: 600) + window.center() + window.makeKeyAndOrderFront(nil) + self.window = window + + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} diff --git a/mthread_draw/app.py b/mthread_draw/app.py deleted file mode 100644 index 4ac3ca9..0000000 --- a/mthread_draw/app.py +++ /dev/null @@ -1,697 +0,0 @@ -"""The MThread Draw desktop window.""" - -from __future__ import annotations - -import os -import tempfile -import threading -import traceback - -import customtkinter as ctk -import tkinter as tk -from tkinter import filedialog, messagebox -from PIL import Image, ImageTk - -from mthread import Device, Recorder, Session, VectorizeSettings, Vectorizer, replay, simulate -from mthread.errors import MThreadError - -from .geometry import CanvasView, place_paths - -CANVAS_MARGIN = 40 -IMAGE_TYPES = [("Images", "*.png *.jpg *.jpeg *.bmp *.webp"), ("All files", "*.*")] -SESSION_TYPES = [("MThread Draw recording", "*.json"), ("All files", "*.*")] - - -class App: - """Main window. Owns the device connection shared by both tabs.""" - - #: Plain words for the two tracing methods, because "Canny" and "edge - #: tangent flow" tell somebody choosing between them nothing at all. - TRACERS = { - "Buildings, machines, objects": "canny", - "Portraits, animals, nature": "flow", - } - - def __init__(self): - ctk.set_appearance_mode("Dark") - ctk.set_default_color_theme("blue") - - self.root = ctk.CTk() - self.root.title("MThread Draw - draw, record and replay on Android") - self.root.geometry("1280x860") - self.root.minsize(1000, 700) - - self.device: Device | None = None - self.vectorizer = Vectorizer() - self.recorder: Recorder | None = None - self.session: Session | None = None - - self.is_busy = False - self.cancel_requested = False - - self.preview_image: Image.Image | None = None - self.tk_image = None - self.canvas_image_item = None - self.background_image: Image.Image | None = None - self.background_tk = None - - self.image_scale = 1.0 - self.image_pos = [0.0, 0.0] - self.screen_size = (1080, 2400) - self.phone_rect = (CANVAS_MARGIN, CANVAS_MARGIN, 300, 600) - self._last_mouse = (0, 0) - self._placed = False - - self.root.grid_columnconfigure(0, weight=1) - self.root.grid_rowconfigure(1, weight=1) - self._build_header() - self._build_tabs() - self.root.bind("", self._on_window_configure) - - # ------------------------------------------------------------------- setup - - def _build_header(self) -> None: - header = ctk.CTkFrame(self.root, corner_radius=0) - header.grid(row=0, column=0, sticky="ew") - header.grid_columnconfigure(4, weight=1) - - ctk.CTkLabel(header, text="MThread Draw", font=ctk.CTkFont(size=20, weight="bold")).grid( - row=0, column=0, padx=(20, 16), pady=12 - ) - self.btn_connect = ctk.CTkButton(header, text="Connect device", width=140, command=self.connect) - self.btn_connect.grid(row=0, column=1, padx=6, pady=12) - self.btn_capture = ctk.CTkButton( - header, text="Capture screen", width=140, fg_color="gray30", command=self.capture_screen - ) - self.btn_capture.grid(row=0, column=2, padx=6, pady=12) - self.lbl_status = ctk.CTkLabel(header, text="Disconnected", text_color="gray70") - self.lbl_status.grid(row=0, column=3, padx=16, pady=12) - - self.progress = ctk.CTkProgressBar(header, width=220) - self.progress.set(0) - self.progress.grid(row=0, column=5, padx=20, pady=12, sticky="e") - - def _build_tabs(self) -> None: - self.tabs = ctk.CTkTabview(self.root) - self.tabs.grid(row=1, column=0, sticky="nsew", padx=16, pady=(8, 16)) - self.tab_draw = self.tabs.add("Draw image") - self.tab_record = self.tabs.add("Record and replay") - self._build_draw_tab() - self._build_record_tab() - - def _build_draw_tab(self) -> None: - tab = self.tab_draw - tab.grid_columnconfigure(1, weight=1) - tab.grid_rowconfigure(0, weight=1) - - side = ctk.CTkScrollableFrame(tab, width=280, label_text="Drawing") - side.grid(row=0, column=0, sticky="nsw", padx=(0, 12), pady=4) - - ctk.CTkButton(side, text="Load image", command=self.load_image).pack(fill="x", pady=(4, 6)) - ctk.CTkButton(side, text="Centre on screen", fg_color="gray30", command=self.center_image).pack( - fill="x", pady=(0, 12) - ) - - ctk.CTkLabel(side, text="What is in the picture", anchor="w").pack(fill="x") - # Two tracers, and which one suits depends entirely on the subject - # rather than on any setting: one keeps every edge it can find, which is - # what a machine or a building is made of, and the other follows the - # direction lines run in, which is what a face or an animal is made of. - self.menu_method = ctk.CTkOptionMenu( - side, values=list(self.TRACERS), command=self._on_setting_change) - self.menu_method.set(next(iter(self.TRACERS))) - self.menu_method.pack(fill="x", pady=(2, 10)) - - ctk.CTkLabel(side, text="Edge sensitivity", anchor="w").pack(fill="x") - self.slider_sensitivity = ctk.CTkSlider(side, from_=1, to=10, number_of_steps=9, command=self._on_setting_change) - self.slider_sensitivity.set(5) - self.slider_sensitivity.pack(fill="x", pady=(0, 10)) - - ctk.CTkLabel(side, text="Detail", anchor="w").pack(fill="x") - self.slider_detail = ctk.CTkSlider(side, from_=1, to=10, number_of_steps=9, command=self._on_setting_change) - self.slider_detail.set(5) - self.slider_detail.pack(fill="x", pady=(0, 10)) - - self.chk_remove_bg = ctk.CTkCheckBox(side, text="Remove background (needs rembg)", command=self._on_setting_change) - self.chk_remove_bg.pack(fill="x", pady=(0, 10)) - - self.chk_pointer = ctk.CTkCheckBox(side, text="Show touches while drawing") - self.chk_pointer.pack(fill="x", pady=(0, 12)) - - self.lbl_speed_value = ctk.CTkLabel(side, text="Speed 1.0x", anchor="w") - self.lbl_speed_value.pack(fill="x") - self.slider_speed = ctk.CTkSlider(side, from_=-2, to=2, number_of_steps=16, - command=self._on_speed_setting) - self.slider_speed.set(0) - self.slider_speed.pack(fill="x", pady=(0, 10)) - - self.lbl_human_value = ctk.CTkLabel(side, text="Draw like a hand off", anchor="w") - self.lbl_human_value.pack(fill="x") - self.slider_human = ctk.CTkSlider(side, from_=0, to=3, number_of_steps=12, - command=self._on_human_setting) - self.slider_human.set(0) - self.slider_human.pack(fill="x", pady=(0, 12)) - - ctk.CTkLabel(side, text="Calibration offset X / Y (px)", anchor="w").pack(fill="x") - offsets = ctk.CTkFrame(side, fg_color="transparent") - offsets.pack(fill="x", pady=(2, 12)) - self.entry_offset_x = ctk.CTkEntry(offsets, width=80, placeholder_text="0") - self.entry_offset_x.pack(side="left", padx=(0, 8)) - self.entry_offset_y = ctk.CTkEntry(offsets, width=80, placeholder_text="0") - self.entry_offset_y.pack(side="left") - - self.lbl_paths = ctk.CTkLabel(side, text="No image loaded", anchor="w", text_color="gray70") - self.lbl_paths.pack(fill="x", pady=(0, 2)) - - self.lbl_method = ctk.CTkLabel(side, text="", anchor="w", justify="left", - wraplength=250, text_color="gray60") - self.lbl_method.pack(fill="x", pady=(0, 12)) - - self.btn_draw = ctk.CTkButton(side, text="START DRAWING", height=42, fg_color="#2e8b57", command=self.start_drawing) - self.btn_draw.pack(fill="x", pady=(0, 6)) - ctk.CTkButton(side, text="Stop", fg_color="#a83232", command=self.cancel).pack(fill="x") - - self.canvas = tk.Canvas(tab, bg="#1a1a1a", highlightthickness=0) - self.canvas.grid(row=0, column=1, sticky="nsew", pady=4) - self.canvas.bind("", self._on_mouse_down) - self.canvas.bind("", self._on_mouse_drag) - self.canvas.bind("", self._on_mouse_wheel) - self.canvas.bind("", self._on_mouse_wheel) - self.canvas.bind("", self._on_mouse_wheel) - - def _build_record_tab(self) -> None: - tab = self.tab_record - tab.grid_columnconfigure(0, weight=1) - - intro = ( - "Record what you do on the phone, save it to a file, and replay it later.\n" - "Useful for regression passes: capture the steps once, then run them on every build." - ) - ctk.CTkLabel(tab, text=intro, justify="left", anchor="w", text_color="gray70").grid( - row=0, column=0, sticky="ew", padx=16, pady=(16, 12) - ) - - capture = ctk.CTkFrame(tab) - capture.grid(row=1, column=0, sticky="ew", padx=16, pady=8) - capture.grid_columnconfigure(3, weight=1) - - self.btn_record = ctk.CTkButton(capture, text="Start recording", width=160, fg_color="#a83232", command=self.toggle_recording) - self.btn_record.grid(row=0, column=0, padx=12, pady=14) - self.lbl_record = ctk.CTkLabel(capture, text="Idle", text_color="gray70") - self.lbl_record.grid(row=0, column=1, padx=12) - ctk.CTkButton(capture, text="Save recording", width=150, command=self.save_session).grid(row=0, column=2, padx=8) - ctk.CTkButton(capture, text="Open recording", width=150, fg_color="gray30", command=self.open_session).grid( - row=0, column=3, padx=8, sticky="w" - ) - - playback = ctk.CTkFrame(tab) - playback.grid(row=2, column=0, sticky="ew", padx=16, pady=8) - playback.grid_columnconfigure(5, weight=1) - - ctk.CTkLabel(playback, text="Speed").grid(row=0, column=0, padx=(12, 6), pady=14) - self.slider_speed = ctk.CTkSlider(playback, from_=0.25, to=4.0, number_of_steps=15, width=180, command=self._on_speed_change) - self.slider_speed.set(1.0) - self.slider_speed.grid(row=0, column=1, padx=6) - self.lbl_speed = ctk.CTkLabel(playback, text="1.00x", width=60) - self.lbl_speed.grid(row=0, column=2, padx=6) - - ctk.CTkLabel(playback, text="Repeat").grid(row=0, column=3, padx=(24, 6)) - self.entry_repeat = ctk.CTkEntry(playback, width=70, placeholder_text="1") - self.entry_repeat.grid(row=0, column=4, padx=6) - - self.btn_replay = ctk.CTkButton(playback, text="Replay", width=140, fg_color="#2e8b57", command=self.start_replay) - self.btn_replay.grid(row=0, column=5, padx=12, sticky="e") - - self.txt_session = ctk.CTkTextbox(tab, height=260) - self.txt_session.grid(row=3, column=0, sticky="nsew", padx=16, pady=(8, 16)) - tab.grid_rowconfigure(3, weight=1) - self._describe_session() - - # ------------------------------------------------------------------ helpers - - def set_status(self, text: str, colour: str = "gray70") -> None: - self.root.after(0, lambda: self.lbl_status.configure(text=text, text_color=colour)) - - def set_progress(self, fraction: float) -> None: - self.root.after(0, lambda: self.progress.set(max(0.0, min(1.0, fraction)))) - - def require_device(self) -> Device | None: - if self.device is None: - self.set_status("Connect a device first", "orange") - return self.device - - def _run_background(self, target, *args) -> None: - if self.is_busy: - self.set_status("Another operation is still running", "orange") - return - self.is_busy = True - self.cancel_requested = False - - def wrapper(): - try: - target(*args) - except MThreadError as exc: - self.set_status(str(exc), "orange") - except Exception as exc: # pragma: no cover - surfaced in the UI - traceback.print_exc() - self.set_status(f"Error: {exc}", "red") - finally: - self.is_busy = False - self.set_progress(0) - - threading.Thread(target=wrapper, daemon=True).start() - - def cancel(self) -> None: - self.cancel_requested = True - self.set_status("Stopping...", "orange") - - def _should_continue(self) -> bool: - return not self.cancel_requested - - def _int_entry(self, entry, default: int = 0) -> int: - try: - return int(entry.get().strip() or default) - except (ValueError, AttributeError): - return default - - # --------------------------------------------------------------- connection - - def connect(self) -> None: - try: - self.device = Device() - except MThreadError as exc: - self.device = None - self.set_status(str(exc), "orange") - return - try: - self.screen_size = self.device.screen_size - except MThreadError as exc: - self.set_status(str(exc), "orange") - return - detail = f"{self.device.serial} - {self.screen_size[0]}x{self.screen_size[1]}" - try: - touch = self.device.touch_device - detail += f" - touch {touch.path}" - except MThreadError: - detail += " - no raw touch device (slow mode)" - self.set_status(detail, "#4caf50") - self._redraw_phone_rect() - - def capture_screen(self) -> None: - if not self.require_device(): - return - - def work(): - self.set_status("Capturing screen...", "yellow") - path = os.path.join(tempfile.gettempdir(), "mthread_draw_screen.png") - self.device.screenshot(path) - self.background_image = Image.open(path).copy() - self.set_status("Screen captured", "#4caf50") - self.root.after(0, self._redraw_phone_rect) - - self._run_background(work) - - # -------------------------------------------------------------------- canvas - - def _on_window_configure(self, event=None) -> None: - if getattr(event, "widget", None) is self.root: - self.root.after_idle(self._redraw_phone_rect) - - def _current_view(self) -> CanvasView: - x, y, width, height = self.phone_rect - return CanvasView(origin=(x, y), size=(width, height), screen=self.screen_size) - - def _redraw_phone_rect(self) -> None: - canvas_w = max(self.canvas.winfo_width(), 200) - canvas_h = max(self.canvas.winfo_height(), 200) - screen_w, screen_h = self.screen_size - - scale = min((canvas_w - 2 * CANVAS_MARGIN) / screen_w, (canvas_h - 2 * CANVAS_MARGIN) / screen_h) - scale = max(scale, 0.01) - width = int(screen_w * scale) - height = int(screen_h * scale) - x = (canvas_w - width) // 2 - y = (canvas_h - height) // 2 - self.phone_rect = (x, y, width, height) - - self.canvas.delete("phone") - if self.background_image is not None: - self.background_tk = ImageTk.PhotoImage(self.background_image.resize((width, height), Image.LANCZOS)) - self.canvas.create_image(x, y, image=self.background_tk, anchor="nw", tags="phone") - self.canvas.create_rectangle(x, y, x + width, y + height, outline="#5a5a5a", width=2, tags="phone") - self.canvas.create_text( - x, y - 12, text=f"{screen_w} x {screen_h}", fill="#8a8a8a", anchor="nw", tags="phone" - ) - self.canvas.tag_lower("phone") - - if not self._placed and self.preview_image is not None: - self.center_image() - - def _on_mouse_down(self, event) -> None: - self._last_mouse = (event.x, event.y) - - def _on_mouse_drag(self, event) -> None: - dx = event.x - self._last_mouse[0] - dy = event.y - self._last_mouse[1] - if self.canvas_image_item is not None: - self.canvas.move(self.canvas_image_item, dx, dy) - self.image_pos[0] += dx - self.image_pos[1] += dy - self._last_mouse = (event.x, event.y) - - def _on_mouse_wheel(self, event) -> None: - if self.preview_image is None: - return - delta = getattr(event, "delta", 0) - if getattr(event, "num", None) == 5 or delta < 0: - factor = 0.9 - elif getattr(event, "num", None) == 4 or delta > 0: - factor = 1.1 - else: - return - new_scale = self.image_scale * factor - if not 0.02 <= new_scale <= 20: - return - self.image_pos[0] = event.x - (event.x - self.image_pos[0]) * factor - self.image_pos[1] = event.y - (event.y - self.image_pos[1]) * factor - self.image_scale = new_scale - self._redraw_preview() - - def _redraw_preview(self) -> None: - if self.preview_image is None: - return - width = max(1, int(self.preview_image.width * self.image_scale)) - height = max(1, int(self.preview_image.height * self.image_scale)) - self.tk_image = ImageTk.PhotoImage(self.preview_image.resize((width, height), Image.NEAREST)) - if self.canvas_image_item is not None: - self.canvas.delete(self.canvas_image_item) - self.canvas_image_item = self.canvas.create_image( - self.image_pos[0], self.image_pos[1], image=self.tk_image, anchor="nw", tags="preview" - ) - - def center_image(self) -> None: - if self.preview_image is None: - return - x, y, width, height = self.phone_rect - self.image_scale = min( - width / self.preview_image.width, height / self.preview_image.height - ) * 0.9 - shown_w = self.preview_image.width * self.image_scale - shown_h = self.preview_image.height * self.image_scale - self.image_pos = [x + (width - shown_w) / 2, y + (height - shown_h) / 2] - self._placed = True - self._redraw_preview() - - # ------------------------------------------------------------------ drawing - - @property - def tracer(self) -> str: - return self.TRACERS.get(self.menu_method.get(), "canny") - - def _settings(self) -> VectorizeSettings: - return VectorizeSettings.from_sliders( - self.slider_sensitivity.get(), - self.slider_detail.get(), - method=self.tracer, - remove_background=bool(self.chk_remove_bg.get()), - ) - - @property - def draw_speed(self) -> float: - """The speed slider, as a multiplier. It is exponential: the useful - range runs from a quarter speed to four times, and a linear slider - spends most of its travel in places nobody wants.""" - return float(2 ** self.slider_speed.get()) - - def _on_speed_setting(self, _value=None) -> None: - self.lbl_speed_value.configure(text=f"Speed {self.draw_speed:.2f}x") - self._refresh_estimate() - - def _on_human_setting(self, _value=None) -> None: - amount = float(self.slider_human.get()) - text = "off" if amount <= 0 else f"{amount:.2f}" - self.lbl_human_value.configure(text=f"Draw like a hand {text}") - self._refresh_estimate() - - def _refresh_estimate(self) -> None: - """Say which injection path will be used, and how long it will take. - - On a device that refuses raw touch events the drawing is minutes rather - than seconds, and a progress bar creeping along with no explanation is - indistinguishable from one that has hung. - """ - paths = self.vectorizer.paths - if not paths: - self.lbl_method.configure(text="") - return - human = float(self.slider_human.get()) - if human > 0: - # The simulation changes the point count - that is the velocity - # profile - so the estimate has to be made on what will be sent. - paths = simulate(paths, human, seed=0) - if self.device is None: - self.lbl_method.configure(text="Connect a device to estimate the time.") - return - - try: - raw = self.device.supports_raw_touch - seconds = self.device.estimate_duration( - paths, method="raw" if raw else "input", speed=self.draw_speed) - except MThreadError as exc: - self.lbl_method.configure(text=str(exc)) - return - - minutes, secs = divmod(int(seconds + 0.5), 60) - span = f"{minutes} min {secs} s" if minutes else f"{secs} s" - if raw: - self.lbl_method.configure( - text=f"Raw touch events, about {span}.", text_color="gray60") - else: - self.lbl_method.configure( - text=(f"This device refuses raw touch events, so drawing goes through " - f"Android's own input injection: about {span}. Lower Detail to " - f"cut that down."), - text_color="#c9a227") - - def _on_setting_change(self, _value=None) -> None: - if self.vectorizer.original_image is not None: - self.refresh_preview() - - def load_image(self) -> None: - path = filedialog.askopenfilename(filetypes=IMAGE_TYPES) - if not path: - return - try: - self.vectorizer.load_image(path) - except ValueError as exc: - messagebox.showerror("MThread Draw", str(exc)) - return - self._placed = False - self.refresh_preview() - - def refresh_preview(self) -> None: - settings = self._settings() - - def work(): - self.set_status("Processing image...", "yellow") - preview, paths = self.vectorizer.process(settings) - points = sum(len(p) for p in paths) - - def apply(): - self.preview_image = preview - self.lbl_paths.configure(text=f"{len(paths)} strokes, {points} points") - self._refresh_estimate() - if not self._placed: - self.center_image() - else: - self._redraw_preview() - self.set_status("Ready", "#4caf50") - - self.root.after(0, apply) - - self._run_background(work) - - def start_drawing(self) -> None: - if not self.require_device() or self.preview_image is None: - if self.preview_image is None: - self.set_status("Load an image first", "orange") - return - - settings = self._settings() - view = self._current_view() - origin = tuple(self.canvas.coords(self.canvas_image_item) or self.image_pos) - scale = self.image_scale - offset = (self._int_entry(self.entry_offset_x), self._int_entry(self.entry_offset_y)) - show_touches = bool(self.chk_pointer.get()) - speed = self.draw_speed - human = float(self.slider_human.get()) - - def work(): - self.set_status("Building paths...", "yellow") - _, paths = self.vectorizer.process(settings) - placed = place_paths(paths, view, origin, scale, offset) - if not placed: - self.set_status("Nothing to draw at this position", "orange") - return - - raw = self.device.supports_raw_touch - method = "raw" if raw else "input" - costed = simulate(placed, human, seed=0) if human > 0 else placed - seconds = self.device.estimate_duration(costed, method=method, speed=speed) - minutes, secs = divmod(int(seconds + 0.5), 60) - span = f"{minutes}m {secs}s" if minutes else f"{secs}s" - how = "raw events" if raw else "input injection (slow)" - - if show_touches: - self.device.set_pointer_location(True) - try: - self.set_status(f"Drawing {len(placed)} strokes via {how}, about {span}...", - "yellow") - self.device.draw_paths( - placed, - method=method, - speed=speed, - human=human, - progress=lambda done, total: self.set_progress(done / max(total, 1)), - should_continue=self._should_continue, - ) - finally: - if show_touches: - self.device.set_pointer_location(False) - self.set_status("Stopped" if self.cancel_requested else "Drawing finished", "#4caf50") - - self._run_background(work) - - # ------------------------------------------------------------ record/replay - - def toggle_recording(self) -> None: - if self.recorder is not None and self.recorder.is_recording: - self._stop_recording() - else: - self._start_recording() - - def _start_recording(self) -> None: - if not self.require_device(): - return - self.recorder = Recorder(self.device) - try: - self.recorder.start() - except MThreadError as exc: - self.set_status(str(exc), "orange") - return - self.btn_record.configure(text="Stop recording", fg_color="#c25a00") - self.set_status("Recording - interact with the phone", "#c25a00") - self._poll_recording() - - def _poll_recording(self) -> None: - if self.recorder is None or not self.recorder.is_recording: - return - self.lbl_record.configure(text=f"Recording... {self.recorder.event_count} events", text_color="#c25a00") - self.root.after(250, self._poll_recording) - - def _stop_recording(self) -> None: - if self.recorder is None: - return - self.session = self.recorder.stop() - error = self.recorder.error - self.recorder = None - self.btn_record.configure(text="Start recording", fg_color="#a83232") - if error: - self.set_status(f"Recorder error: {error}", "red") - else: - self.set_status("Recording stopped", "#4caf50") - self._describe_session() - - def _describe_session(self) -> None: - box = self.txt_session - box.configure(state="normal") - box.delete("1.0", "end") - if self.session is None or not self.session.events: - box.insert("1.0", "No recording loaded.\n\nPress 'Start recording', touch the phone, then stop.") - self.lbl_record.configure(text="Idle", text_color="gray70") - else: - session = self.session - lines = [ - f"Events : {len(session.events)}", - f"Duration : {session.duration:.2f} s", - f"Recorded : {session.created_at}", - f"Device : {session.device_serial or 'unknown'}", - f"Screen : {session.screen_size or 'unknown'}", - f"Input paths : {', '.join(session.devices) or 'none'}", - "", - "First events (time, device, type, code, value):", - ] - for event in session.events[:25]: - lines.append(f" {event.t:8.3f} {event.device} {event.type:>3} {event.code:>4} {event.value}") - if len(session.events) > 25: - lines.append(f" ... {len(session.events) - 25} more") - box.insert("1.0", "\n".join(lines)) - self.lbl_record.configure( - text=f"{len(session.events)} events / {session.duration:.1f}s", text_color="#4caf50" - ) - box.configure(state="disabled") - - def save_session(self) -> None: - if self.session is None or not self.session.events: - self.set_status("Nothing recorded yet", "orange") - return - path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=SESSION_TYPES) - if not path: - return - self.session.save(path) - self.set_status(f"Saved to {os.path.basename(path)}", "#4caf50") - - def open_session(self) -> None: - path = filedialog.askopenfilename(filetypes=SESSION_TYPES) - if not path: - return - try: - self.session = Session.load(path) - except (ValueError, OSError) as exc: - messagebox.showerror("MThread Draw", f"Could not open the recording:\n{exc}") - return - self.set_status(f"Loaded {os.path.basename(path)}", "#4caf50") - self._describe_session() - - def _on_speed_change(self, value) -> None: - self.lbl_speed.configure(text=f"{float(value):.2f}x") - - def start_replay(self) -> None: - if not self.require_device(): - return - if self.session is None or not self.session.events: - self.set_status("Load or record something first", "orange") - return - speed = float(self.slider_speed.get()) - repeat = max(1, self._int_entry(self.entry_repeat, 1)) - - def work(): - self.set_status(f"Replaying at {speed:.2f}x...", "yellow") - try: - replay( - self.device, - self.session, - speed=speed, - repeat=repeat, - progress=lambda done, total: self.set_progress(done / max(total, 1)), - should_continue=self._should_continue, - ) - except ValueError as exc: - self.set_status(str(exc), "orange") - return - self.set_status("Stopped" if self.cancel_requested else "Replay finished", "#4caf50") - - self._run_background(work) - - # --------------------------------------------------------------------- main - - def run(self) -> None: - self.root.mainloop() - - -def main() -> None: - App().run() - - -if __name__ == "__main__": - main() diff --git a/packaging/MThreadDraw.spec b/packaging/MThreadDraw.spec index 565f3d0..e43e800 100644 --- a/packaging/MThreadDraw.spec +++ b/packaging/MThreadDraw.spec @@ -1,58 +1,58 @@ # -*- mode: python ; coding: utf-8 -*- -"""PyInstaller build definition for the MThreadDraw desktop app. +"""PyInstaller build definition for the MThread Draw engine. -Used unchanged on all three platforms and both locally and in CI, so a release -build is the same build a maintainer can reproduce: +The engine and nothing else. It used to build a Tk window as well, and that +window is gone: on Windows the native WinUI front end replaced it, on macOS a +native one does, and a Tk window that started slowly and looked like nothing +else on either platform was not worth keeping for the sake of symmetry. + +What is left is what both front ends need - one console program that speaks the +JSON protocol in mthread_draw.server, with the injector jar and adb inside it: python tools/build_app.py One directory rather than one file, deliberately. A one-file build unpacks -itself into a temporary directory on every launch - with OpenCV and adb inside -that is several seconds of nothing happening, and antivirus software treats the -self-extraction as suspicious. The installers hide the directory anyway. +itself into a temporary directory on every launch, and with OpenCV and adb +inside that is several seconds of nothing happening; antivirus software also +treats the self-extraction as suspicious. The installer hides the directory. adb comes from the platform-tools directory, put there by -tools/fetch_platform_tools.py. -If it is absent the build still works; the app then falls back to whatever adb -the machine has, exactly like a source checkout. +tools/fetch_platform_tools.py. If it is absent the build still works and the +engine falls back to whatever adb the machine has, exactly like a source +checkout. """ import sys from pathlib import Path -from PyInstaller.utils.hooks import collect_data_files - ROOT = Path(SPECPATH).parent IS_WINDOWS = sys.platform.startswith("win") -IS_MAC = sys.platform == "darwin" VERSION = "1.2.0" -datas = collect_data_files("customtkinter") -datas += [(str(ROOT / "mthread" / "injector.jar"), "mthread")] +datas = [(str(ROOT / "mthread" / "injector.jar"), "mthread")] platform_tools = ROOT / "platform-tools" if platform_tools.is_dir(): datas += [(str(item), "platform-tools") for item in platform_tools.iterdir() if item.is_file()] else: - print("MThreadDraw.spec: platform-tools not found - the app will need adb on PATH") - -icon = str(ROOT / "packaging" / ("mthreaddraw.ico" if IS_WINDOWS else "mthreaddraw.png")) + print("MThreadDraw.spec: platform-tools not found - the engine will need adb on PATH") analysis = Analysis( - [str(ROOT / "tools" / "pyinstaller_entry.py")], + [str(ROOT / "tools" / "engine_entry.py")], pathex=[str(ROOT)], binaries=[], datas=datas, # mthread imports its vectoriser through __getattr__ so that the core # library stays dependency-free. PyInstaller cannot see through that, and # leaves the module out unless it is named here. - hiddenimports=["customtkinter", "mthread.vectorize"], + hiddenimports=["mthread.vectorize"], hookspath=[], runtime_hooks=[], # rembg and its onnxruntime are optional and enormous; someone who wants - # background removal can install the library from source. - excludes=["rembg", "onnxruntime", "matplotlib", "pytest", "tkinter.test"], + # background removal can install the library from source. Tkinter goes with + # the window that used it. + excludes=["rembg", "onnxruntime", "matplotlib", "pytest", "tkinter", "customtkinter"], noarchive=False, ) @@ -63,68 +63,21 @@ exe = EXE( analysis.scripts, [], exclude_binaries=True, - name="MThreadDraw", + name="mthread-draw-engine", debug=False, strip=False, upx=False, - console=False, - icon=icon, + # A console program on purpose: it speaks JSON on stdin and stdout, and the + # front end starts it with the window hidden. + console=True, + icon=str(ROOT / "packaging" / ("mthreaddraw.ico" if IS_WINDOWS else "mthreaddraw.png")), ) -collected = COLLECT( +engine = COLLECT( exe, analysis.binaries, analysis.datas, strip=False, upx=False, - name="MThreadDraw", -) - -# The same engine again, as a console program the WinUI front end can launch. -# It shares the analysis, so it costs a second link rather than a second scan. -engine_analysis = Analysis( - [str(ROOT / "tools" / "engine_entry.py")], - pathex=[str(ROOT)], - binaries=[], - datas=datas, - hiddenimports=["customtkinter", "mthread.vectorize"], - excludes=["rembg", "onnxruntime", "matplotlib", "pytest", "tkinter.test"], - noarchive=False, -) -engine_pyz = PYZ(engine_analysis.pure) -engine_exe = EXE( - engine_pyz, - engine_analysis.scripts, - [], - exclude_binaries=True, name="mthread-draw-engine", - debug=False, - strip=False, - upx=False, - console=True, ) -engine = COLLECT( - engine_exe, - engine_analysis.binaries, - engine_analysis.datas, - strip=False, - upx=False, - name="mthread-draw-engine", -) - -if IS_MAC: - app = BUNDLE( - collected, - name="MThreadDraw.app", - icon=icon, - bundle_identifier="io.github.maxawer.mthread_draw", - version=VERSION, - info_plist={ - "CFBundleShortVersionString": VERSION, - "CFBundleVersion": VERSION, - "NSHighResolutionCapable": True, - # The app talks to a phone over USB; without this, macOS refuses - # the connection instead of prompting. - "NSAppleEventsUsageDescription": "MThreadDraw drives adb to reach your device.", - }, - ) diff --git a/packaging/MThreadDraw.wxs b/packaging/MThreadDraw.wxs index 2979357..8c590bf 100644 --- a/packaging/MThreadDraw.wxs +++ b/packaging/MThreadDraw.wxs @@ -1,20 +1,31 @@ - + - + @@ -51,13 +62,57 @@ - + + + + + + + + + + + + + + + + + - + diff --git a/pyproject.toml b/pyproject.toml index 884c3ea..f247c60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,9 @@ dependencies = [] [project.optional-dependencies] draw = ["opencv-python>=4.5", "numpy>=1.21", "Pillow>=9.0"] -gui = ["mthread-draw[draw]", "customtkinter>=5.2"] bg = ["rembg>=2.0"] dev = ["pytest>=7.0"] -all = ["mthread-draw[gui,bg]"] +all = ["mthread-draw[draw,bg]"] [project.urls] Homepage = "https://github.com/MAXAWER/MThread-Draw" @@ -55,9 +54,6 @@ Terms = "https://github.com/MAXAWER/MThread-Draw/blob/main/TERMS.md" [project.scripts] mthread = "mthread.cli:main" -[project.gui-scripts] -mthread_draw = "mthread_draw.app:main" - [tool.setuptools] packages = ["mthread", "mthread_draw"] diff --git a/requirements.txt b/requirements.txt index b0bf861..8037b96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -# Everything needed to run the desktop app. +# Everything the engine needs. The window is native on each platform and +# brings its own dependencies: WinUI 3 on Windows, SwiftUI on macOS. # The mthread library alone needs nothing beyond the standard library: # pip install -e . -customtkinter>=5.2,<6 opencv-python>=4.5 numpy>=1.21 Pillow>=9.0 diff --git a/tests/test_gui.py b/tests/test_gui.py deleted file mode 100644 index f4d91f4..0000000 --- a/tests/test_gui.py +++ /dev/null @@ -1,26 +0,0 @@ -import unittest - -customtkinter = None -try: - import customtkinter # noqa: F401 -except Exception: # pragma: no cover - depends on the environment - pass - - -@unittest.skipIf(customtkinter is None, "customtkinter is not installed") -class GuiImportTests(unittest.TestCase): - """The GUI needs a display to run, but it should at least import cleanly.""" - - def test_app_class_is_importable(self): - from mthread_draw.app import App - - self.assertTrue(hasattr(App, "run")) - - def test_entry_point_exists(self): - from mthread_draw.app import main - - self.assertTrue(callable(main)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/build_app.py b/tools/build_app.py index 88dd932..f3e38fb 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -1,14 +1,19 @@ -"""Build the packaged MThread Draw application, and its installer. +"""Build the engine, the front end for this platform, and the installer. One command, the same one CI runs, so a release can be reproduced locally: - python tools/build_app.py # app only - python tools/build_app.py --msi # + Windows installer (needs WiX 5) - python tools/build_app.py --dmg # + macOS disk image (macOS only) - python tools/build_app.py --archive # + a .tar.gz / .zip of the app folder + python tools/build_app.py # the engine on its own + python tools/build_app.py --winui # + the Windows front end (Windows) + python tools/build_app.py --msi # + the Windows installer (needs WiX 5) -The result is self-contained: Python, OpenCV, Tk and adb all travel inside it, -so the person installing MThread Draw installs nothing else. +Everything is built in a scratch directory outside the checkout; dist/ receives +only the finished installer and zip. + +The engine is self-contained: Python, OpenCV and adb all travel inside it, so +the person installing MThread Draw installs nothing else. + +There is no Tk window any more. Windows gets the WinUI front end and macOS a +native one; both drive this same engine over a pipe. Requires PyInstaller, and for --msi the `wix` dotnet tool: @@ -25,12 +30,26 @@ import subprocess import sys import tarfile +import tempfile +import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DIST = ROOT / "dist" BUILD = ROOT / "build" +#: Everything is built here and only the finished artifacts are copied into +#: dist/. This checkout lives in OneDrive on at least one machine, and a sync +#: client holds handles on files while it uploads them: writing several thousand +#: of them into a synced folder failed at a different step every time - deleting +#: last build's output, zipping a DLL, harvesting for the installer - always with +#: an access denied that named a file rather than the cause. Copying four +#: finished files in at the end does not collide. +STAGE = Path(tempfile.gettempdir()) / "mthread-draw-stage" +WORKPATH = STAGE / "work" SPEC = ROOT / "packaging" / "MThreadDraw.spec" +#: Pinned because WiX 6 and later want a separate maintenance-fee +#: licence accepted before they will build unattended. +WIX_VERSION = "5.0.2" IS_WINDOWS = sys.platform.startswith("win") IS_MAC = sys.platform == "darwin" @@ -56,6 +75,34 @@ def run(*command: str, cwd: Path | None = None) -> None: subprocess.run([str(part) for part in command], cwd=str(cwd or ROOT), check=True) +def remove_tree(path: Path, attempts: int = 6) -> None: + """Delete a directory, waiting out whatever is holding a file in it. + + This checkout lives in OneDrive on at least one machine, and a sync client + keeps handles on files while it uploads them. Deleting a few hundred + megabytes of build output therefore fails with an access denied on a + different file each time, and PyInstaller's own cleanup dies the same way + with a message that names a font rather than the cause. Retrying gets past + it; read-only attributes, which sync clients also set, are cleared on the + way. + """ + if not path.exists(): + return + for attempt in range(attempts): + def unlock(func, target, _exc): + os.chmod(target, stat.S_IWRITE) + func(target) + + shutil.rmtree(path, onexc=unlock) + if not path.exists(): + return + time.sleep(0.5 * (attempt + 1)) + raise SystemExit( + f"could not delete {path}. Something is holding a file in it - a file " + f"manager, an antivirus scan, or a sync client. Close it and try again." + ) + + def ensure_icon() -> None: icon = ROOT / "packaging" / ("mthreaddraw.ico" if IS_WINDOWS else "mthreaddraw.png") if not icon.is_file(): @@ -69,31 +116,46 @@ def ensure_platform_tools(skip: bool) -> None: run(sys.executable, ROOT / "tools" / "fetch_platform_tools.py", "--out", ROOT / "platform-tools") -def build_app() -> Path: - app = DIST / ("MThreadDraw.app" if IS_MAC else "MThreadDraw") - # Clear the previous app, not the whole dist directory: dist also holds the - # installers, and on Windows something as ordinary as an open Explorer - # window keeps a handle on the folder itself. - if app.exists(): - shutil.rmtree(app, ignore_errors=True) - run(sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", SPEC) - - if not app.exists(): - raise SystemExit(f"PyInstaller did not produce {app}") - - fix_adb_permissions(app) - selftest(app) - return app - - -def selftest(app: Path) -> None: +def deliver(source: Path) -> Path: + """Copy a finished artifact into dist/, replacing what was there.""" + DIST.mkdir(exist_ok=True) + target = DIST / source.name + if target.exists(): + target.unlink() + shutil.copy2(source, target) + return target + + +def build_engine() -> Path: + """The engine both front ends launch, with Python, OpenCV and adb inside.""" + engine = STAGE / "mthread-draw-engine" + # Clear the previous build, not the whole dist directory: dist also holds + # the installer, and on Windows an open Explorer window is enough to keep a + # handle on the folder itself. + remove_tree(engine) + # PyInstaller's scratch directory goes outside the checkout. This one lives + # in OneDrive, which holds handles on files while it syncs them, and --clean + # then fails to delete last build's localpycs with an access denied that + # says nothing about why. Anywhere the sync client is not watching works. + run(sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", + "--workpath", WORKPATH, "--distpath", STAGE, SPEC) + + if not engine.exists(): + raise SystemExit(f"PyInstaller did not produce {engine}") + + fix_adb_permissions(engine) + selftest(engine) + return engine + + +def selftest(engine: Path) -> None: """Run the packaged app's own completeness check before anything ships. Without this a broken build looks exactly like a working one until someone double-clicks it and gets a traceback in a dialog box. """ - executable = app / "Contents" / "MacOS" / "MThreadDraw" if IS_MAC else app / ( - "MThreadDraw.exe" if IS_WINDOWS else "MThreadDraw") + executable = engine / ("mthread-draw-engine.exe" if IS_WINDOWS + else "mthread-draw-engine") report = BUILD / "selftest.txt" report.unlink(missing_ok=True) @@ -105,7 +167,7 @@ def selftest(app: Path) -> None: raise SystemExit("the packaged build is incomplete - see the self-test output above") -def fix_adb_permissions(app: Path) -> None: +def fix_adb_permissions(engine: Path) -> None: """Restore the execute bit on the bundled adb. PyInstaller copies data files without their mode, so on macOS and Linux the @@ -114,23 +176,50 @@ def fix_adb_permissions(app: Path) -> None: """ if IS_WINDOWS: return - for adb in app.rglob("platform-tools/adb"): + for adb in engine.rglob("platform-tools/adb"): adb.chmod(adb.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - print(f" chmod +x {adb.relative_to(app.parent)}") + print(f" chmod +x {adb.relative_to(engine.parent)}") + + +#: The WiX extensions the installer needs: one for the dialogs, one for the +#: action that offers to start the program at the end. +WIX_EXTENSIONS = ("WixToolset.UI.wixext",) + + +def ensure_wix_extensions() -> None: + """Add the extensions if this machine has not got them. + + `wix extension add -g` is idempotent, so this is cheap to repeat and means a + fresh checkout or a fresh CI runner needs no separate step. + """ + for name in WIX_EXTENSIONS: + run("wix", "extension", "add", "-g", f"{name}/{WIX_VERSION}") def build_msi(app: Path) -> Path: + """The Windows installer, which installs the WinUI front end. + + Not the Tk one: on Windows the native window is the one people should get, + and the Tk window is what macOS and Linux run. The archives still carry it + for anyone who wants it. + """ if not IS_WINDOWS: raise SystemExit("--msi only works on Windows") if shutil.which("wix") is None: raise SystemExit("wix not found. Install it with: dotnet tool install --global wix") - out = DIST / f"MThreadDraw-{version()}-{arch_tag()}.msi" + source = STAGE / "MThreadDraw-WinUI" + if not (source / "MThreadDraw.exe").is_file(): + raise SystemExit("the WinUI front end is not built; --msi implies --winui") + + ensure_wix_extensions() + out = STAGE / f"MThreadDraw-{version()}-{arch_tag()}.msi" run( "wix", "build", ROOT / "packaging" / "MThreadDraw.wxs", "-arch", "x64", + *[arg for name in WIX_EXTENSIONS for arg in ("-ext", name)], "-d", f"Version={version()}", - "-d", f"SourceDir={app}", + "-d", f"SourceDir={source}", "-d", f"IconFile={ROOT / 'packaging' / 'mthreaddraw.ico'}", "-o", out, ) @@ -138,31 +227,10 @@ def build_msi(app: Path) -> Path: # WiX only warns when it harvests nothing - a relative SourceDir resolves # against the .wxs file, not the working directory - and happily writes a # perfectly valid installer that installs no application at all. - if size_mb < 20: + if size_mb < 100: raise SystemExit(f"{out.name} is only {size_mb} MB; the file harvest found nothing") print(f"built {out} ({size_mb} MB)") - return out - - -def build_dmg(app: Path) -> Path: - if not IS_MAC: - raise SystemExit("--dmg only works on macOS") - - out = DIST / f"MThreadDraw-{version()}-{arch_tag()}.dmg" - staging = BUILD / "dmg" - if staging.exists(): - shutil.rmtree(staging) - staging.mkdir(parents=True) - shutil.copytree(app, staging / app.name, symlinks=True) - # The customary drag-to-install layout. - os.symlink("/Applications", staging / "Applications") - - if out.exists(): - out.unlink() - run("hdiutil", "create", "-volname", "MThread Draw", "-srcfolder", staging, - "-ov", "-format", "UDZO", out) - print(f"built {out} ({out.stat().st_size // 1024 // 1024} MB)") - return out + return deliver(out) def build_winui() -> Path: @@ -176,16 +244,15 @@ def build_winui() -> Path: raise SystemExit("--winui only works on Windows") project = ROOT / "winui" / "MThreadDraw.WinUI.csproj" - out = DIST / "MThreadDraw-WinUI" + out = STAGE / "MThreadDraw-WinUI" run("dotnet", "publish", project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "-o", out) - engine = DIST / "mthread-draw-engine" + engine = STAGE / "mthread-draw-engine" if not engine.is_dir(): raise SystemExit("the engine was not built; run without --no-adb first") target = out / "engine" - if target.exists(): - shutil.rmtree(target) + remove_tree(target) shutil.copytree(engine, target) size = sum(f.stat().st_size for f in out.rglob("*") if f.is_file()) @@ -194,31 +261,19 @@ def build_winui() -> Path: # A release asset has to be one file, and a folder of four thousand is a # poor thing to ask anyone to download by hand. archive = Path(shutil.make_archive( - str(DIST / f"MThreadDraw-WinUI-{version()}-{arch_tag()}"), "zip", + str(STAGE / f"MThreadDraw-WinUI-{version()}-{arch_tag()}"), "zip", root_dir=out.parent, base_dir=out.name)) print(f"built {archive} ({archive.stat().st_size // 1024 // 1024} MB)") - return out - - -def build_archive(app: Path) -> Path: - """A plain archive of the app folder, for people who do not want an installer.""" - stem = f"MThreadDraw-{version()}-{platform.system().lower()}-{arch_tag()}" - if IS_WINDOWS: - out = Path(shutil.make_archive(str(DIST / stem), "zip", root_dir=app.parent, base_dir=app.name)) - else: - out = DIST / f"{stem}.tar.gz" - with tarfile.open(out, "w:gz") as archive: - archive.add(app, arcname=app.name) - print(f"built {out} ({out.stat().st_size // 1024 // 1024} MB)") + deliver(archive) return out def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--msi", action="store_true", help="also build the Windows installer") - parser.add_argument("--dmg", action="store_true", help="also build the macOS disk image") - parser.add_argument("--archive", action="store_true", help="also build a zip / tar.gz") + parser.add_argument("--msi", action="store_true", + help="also build the Windows installer, which installs the " + "WinUI front end and therefore builds it too") parser.add_argument("--winui", action="store_true", help="also publish the WinUI 3 front end with the engine inside it") parser.add_argument("--no-adb", action="store_true", help="do not bundle platform-tools") @@ -226,17 +281,14 @@ def main() -> int: ensure_icon() ensure_platform_tools(args.no_adb) - app = build_app() - print(f"built {app}") + engine = build_engine() + print(f"built {engine}") - if args.msi: - build_msi(app) - if args.dmg: - build_dmg(app) - if args.winui: + if args.winui or args.msi: build_winui() - if args.archive: - build_archive(app) + if args.msi: + build_msi(engine) + print(f"artifacts in {DIST}") return 0 diff --git a/tools/build_macos.py b/tools/build_macos.py new file mode 100644 index 0000000..fef7c76 --- /dev/null +++ b/tools/build_macos.py @@ -0,0 +1,175 @@ +"""Build the macOS front end, wrap it in a bundle, and make a disk image. + + python tools/build_macos.py # -> dist/MThreadDraw.app + python tools/build_macos.py --dmg # + dist/MThreadDraw--.dmg + +macOS only. The front end is a Swift package rather than an Xcode project, so +this assembles the .app around the built binary: a bundle is a directory with a +plist in it, and hand-assembling one keeps the build to `swift build` with no +project file to drift out of sync. + +The engine goes inside the bundle's Resources, which is where Engine.swift looks +for it, so the application a person drags to Applications carries Python, OpenCV +and adb with it and needs nothing installed. +""" + +from __future__ import annotations + +import argparse +import os +import plistlib +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "dist" +PROJECT = ROOT / "macos" + +BUNDLE_ID = "io.github.maxawer.mthread-draw" + + +def version() -> str: + for line in (ROOT / "mthread_draw" / "__init__.py").read_text(encoding="utf-8").splitlines(): + if line.startswith("__version__"): + scope: dict = {} + exec(line, scope) # noqa: S102 - a single literal assignment + return scope["__version__"] + raise SystemExit("could not find __version__ in mthread_draw/__init__.py") + + +def arch_tag() -> str: + import platform + return {"arm64": "arm64", "x86_64": "x64"}.get(platform.machine(), platform.machine()) + + +def run(*command, cwd: Path | None = None) -> None: + print("+", " ".join(str(part) for part in command)) + subprocess.run([str(part) for part in command], cwd=str(cwd or ROOT), check=True) + + +def build_binary() -> Path: + run("swift", "build", "-c", "release", cwd=PROJECT) + binary = PROJECT / ".build" / "release" / "MThreadDraw" + if not binary.is_file(): + raise SystemExit(f"swift build did not produce {binary}") + return binary + + +def make_icon() -> Path | None: + """Convert the PNG icon into the .icns a bundle wants. + + iconutil is part of the developer tools and is present on any machine that + can run swift build, so this is not an extra dependency in practice. + """ + source = ROOT / "packaging" / "mthreaddraw.png" + if not source.is_file() or shutil.which("iconutil") is None: + return None + + iconset = DIST / "MThreadDraw.iconset" + if iconset.exists(): + shutil.rmtree(iconset) + iconset.mkdir(parents=True) + + # The sizes a bundle icon is expected to carry; sips is the built-in + # resizer, so nothing outside the system is needed. + for size in (16, 32, 64, 128, 256, 512): + for scale, suffix in ((1, ""), (2, "@2x")): + pixels = size * scale + run("sips", "-z", pixels, pixels, source, + "--out", iconset / f"icon_{size}x{size}{suffix}.png") + + icns = DIST / "MThreadDraw.icns" + run("iconutil", "-c", "icns", iconset, "-o", icns) + shutil.rmtree(iconset) + return icns + + +def assemble(binary: Path) -> Path: + app = DIST / "MThreadDraw.app" + if app.exists(): + shutil.rmtree(app) + contents = app / "Contents" + (contents / "MacOS").mkdir(parents=True) + (contents / "Resources").mkdir(parents=True) + + shutil.copy2(binary, contents / "MacOS" / "MThreadDraw") + os.chmod(contents / "MacOS" / "MThreadDraw", 0o755) + + icns = make_icon() + if icns is not None: + shutil.copy2(icns, contents / "Resources" / "MThreadDraw.icns") + + plist = { + "CFBundleName": "MThread Draw", + "CFBundleDisplayName": "MThread Draw", + "CFBundleExecutable": "MThreadDraw", + "CFBundleIdentifier": BUNDLE_ID, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": version(), + "CFBundleVersion": version(), + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + # The window is frosted glass; a light-only or dark-only app would look + # wrong against half the desktops it is put on. + "NSRequiresAquaSystemAppearance": False, + # It drives adb to reach the phone. Without this macOS refuses the + # connection instead of prompting for it. + "NSAppleEventsUsageDescription": + "MThread Draw runs adb to reach your Android device.", + } + if icns is not None: + plist["CFBundleIconFile"] = "MThreadDraw" + (contents / "Info.plist").write_bytes(plistlib.dumps(plist)) + + engine = DIST / "mthread-draw-engine" + if engine.is_dir(): + shutil.copytree(engine, contents / "Resources" / "engine") + print(f" engine folded in from {engine}") + else: + print(" no engine in dist/: build it first with tools/build_app.py, or the " + "app will look for a source checkout") + + size = sum(f.stat().st_size for f in app.rglob("*") if f.is_file()) + print(f"built {app} ({size // 1024 // 1024} MB)") + return app + + +def build_dmg(app: Path) -> Path: + out = DIST / f"MThreadDraw-{version()}-{arch_tag()}.dmg" + staging = DIST / "dmg" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + shutil.copytree(app, staging / app.name, symlinks=True) + # The customary drag-to-install layout. + os.symlink("/Applications", staging / "Applications") + + if out.exists(): + out.unlink() + run("hdiutil", "create", "-volname", "MThread Draw", "-srcfolder", staging, + "-ov", "-format", "UDZO", out) + shutil.rmtree(staging) + print(f"built {out} ({out.stat().st_size // 1024 // 1024} MB)") + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dmg", action="store_true", help="also build the disk image") + args = parser.parse_args() + + if sys.platform != "darwin": + raise SystemExit("the macOS front end only builds on macOS") + + DIST.mkdir(exist_ok=True) + app = assemble(build_binary()) + if args.dmg: + build_dmg(app) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/pyinstaller_entry.py b/tools/pyinstaller_entry.py deleted file mode 100644 index 47c64cf..0000000 --- a/tools/pyinstaller_entry.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Entry point for the PyInstaller build. - -``mthread_draw/__main__.py`` uses a relative import, which PyInstaller cannot use as -a top-level script. This module is the same call with an absolute import, plus a -self-test the build pipeline runs before shipping anything. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - - -def selftest(report: str | None = None) -> int: - """Check that the frozen build is actually complete, and say why if not. - - A packaged build breaks in ways a source checkout never does. ``mthread`` - imports its vectoriser lazily, through ``__getattr__``, so PyInstaller's - static analysis cannot see it and silently leaves it out; the bundled adb - can arrive without its execute bit; a data file can go missing. Every one of - those surfaces as a dialog box on the user's first launch. - - Run by tools/build_app.py straight after the build, and by CI, so the - failure lands in a log instead. - """ - lines: list[str] = [] - ok = True - - try: - import cv2 - import numpy - import PIL - - lines.append(f"opencv {cv2.__version__}, numpy {numpy.__version__}, pillow {PIL.__version__}") - except Exception as exc: # pragma: no cover - only reachable in a broken build - ok = False - lines.append(f"imaging stack missing: {exc!r}") - - try: - import customtkinter - - from mthread import Device, Recorder, Session, replay # noqa: F401 - from mthread.vectorize import VectorizeSettings, Vectorizer # noqa: F401 - from mthread_draw.app import App, main # noqa: F401 - - lines.append(f"customtkinter {customtkinter.__version__}, mthread and mthread_draw import cleanly") - except Exception as exc: - ok = False - lines.append(f"application imports failed: {exc!r}") - - try: - from mthread.adb import bundled_candidates, find_adb, run_adb - - path = find_adb() - bundled = any(Path(candidate) == Path(path) for candidate in bundled_candidates()) - version = run_adb(path, ["version"], timeout=20.0).stdout.splitlines()[0] - lines.append(f"adb: {path}") - lines.append(f" {version} ({'bundled' if bundled else 'found on this machine'})") - if not bundled: - lines.append(" warning: this build is not carrying its own adb") - except Exception as exc: - ok = False - lines.append(f"adb unusable: {exc}") - - text = "\n".join(lines) - print(text) - if report: - Path(report).write_text(text + f"\n\nresult: {'ok' if ok else 'FAILED'}\n", encoding="utf-8") - return 0 if ok else 1 - - -def run() -> int: - if "--selftest" in sys.argv: - index = sys.argv.index("--selftest") - report = sys.argv[index + 1] if len(sys.argv) > index + 1 else None - return selftest(report) - - from mthread_draw.app import main - - return main() or 0 - - -if __name__ == "__main__": - raise SystemExit(run())