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())