diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ca894..a529ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,27 +8,73 @@ on: jobs: build: - name: Build ${{ matrix.config }} - runs-on: ubuntu-latest + name: Build ${{ matrix.config }} ${{ matrix.os }} + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - config: [cli, library, daemon] + include: + - os: ubuntu-latest + config: cli + - os: ubuntu-24.04-arm + config: cli + - os: windows-latest + config: cli + - os: windows-11-arm + config: cli + - os: macos-latest + config: cli + - os: ubuntu-latest + config: library + - os: windows-latest + config: library + - os: ubuntu-latest + config: daemon + - os: windows-latest + config: daemon steps: - uses: actions/checkout@v4 - name: Install D uses: dlang-community/setup-dlang@v1 with: - compiler: dmd-latest + compiler: ldc-latest - name: Build run: dub build --config=${{ matrix.config }} - build-tray-windows: - name: Build tray (Windows) - runs-on: windows-latest + + build-tray: + name: Build tray ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - name: Install D uses: dlang-community/setup-dlang@v1 with: - compiler: dmd-latest + compiler: ldc-latest + - name: Install Qt (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y qt6-base-dev + - name: Install Qt (macOS) + if: runner.os == 'macOS' + run: brew install qt + - name: Install Qt (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + python -m pip install aqtinstall + python -m aqt install-qt windows desktop 6.7.3 win64_msvc2019_64 -O "$env:RUNNER_TEMP\Qt" + $qt = Get-ChildItem "$env:RUNNER_TEMP\Qt\6.7.3" -Directory | Select-Object -First 1 + "DFLAGS=-L/LIBPATH:$($qt.FullName)\lib" | Out-File -FilePath $env:GITHUB_ENV -Append + "PATH=$($qt.FullName)\bin;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Qt paths (macOS) + if: runner.os == 'macOS' + run: | + echo "PATH=$(brew --prefix qt)/bin:$PATH" >> "$GITHUB_ENV" + echo "LIBRARY_PATH=$(brew --prefix qt)/lib:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" - name: Build tray run: dub build --config=tray diff --git a/.gitignore b/.gitignore index 29247e4..31dc386 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ Thumbs.db installer/*.exe installer/*.msi installer/output/ +bootstrap.sdl +forge-profiles.sdl +vcs-profiles.sdl diff --git a/docs/modules/ROOT/pages/architecture.adoc b/docs/modules/ROOT/pages/architecture.adoc index 12eb1b6..6c7033d 100644 --- a/docs/modules/ROOT/pages/architecture.adoc +++ b/docs/modules/ROOT/pages/architecture.adoc @@ -54,5 +54,6 @@ Shared schema also lands in `dlang-supplemental/repo-get` (`getForge`). * `AMDphreak/ver` — no CI/releases; multi-VCS UX mostly stubs — not a dependency * `dev-centr/rules-manager` — IPC pattern reused; Electron tray not adopted; no published releases -* `tim-dlang/dqt` — `QSystemTrayIcon` binding proposed upstream; temporary fork `dlang-supplemental/dqt` for git dependencies (prefer git over Dub registry for interim forks). Tray still uses Win32 until the binding lands. +* `dlang-supplemental/dqt` (git dep, pinned commit) — `QSystemTrayIcon` for the tray app; upstream PR pending on `tim-dlang/dqt` +* `repo-get` (DUB `~>0.3.0`) — forge profile schema (`getForge` / `forge-profiles.sdl`) * `repo-get` — Linux release artifacts only; forge profiles added for metadata tooling diff --git a/docs/modules/ROOT/pages/installation.adoc b/docs/modules/ROOT/pages/installation.adoc index 34b8153..93c0c53 100644 --- a/docs/modules/ROOT/pages/installation.adoc +++ b/docs/modules/ROOT/pages/installation.adoc @@ -12,11 +12,13 @@ ---- dub build --config=cli # issues-browser dub build --config=daemon # issuesd -dub build --config=tray # issues-browser-tray (Windows tray) +dub build --config=tray # issues-browser-tray (needs Qt 6 + dqt git fork) dub build --config=application # GUI (dlangui) dub build --config=library ---- +Forge profiles come from the DUB package `repo-get` (`~>0.3.0`). The tray config depends on `dqt:widgets` via git (`dlang-supplemental/dqt` pinned commit), not the Dub registry. + Windows CLI/daemon link against vendored `sqlite3.lib`; `sqlite3.dll` is copied beside the executable. == ProHelp-compatible help diff --git a/docs/modules/ROOT/pages/usage.adoc b/docs/modules/ROOT/pages/usage.adoc index 10e64db..e27ace6 100644 --- a/docs/modules/ROOT/pages/usage.adoc +++ b/docs/modules/ROOT/pages/usage.adoc @@ -40,8 +40,7 @@ IPC (localhost only): `GET /health`, `GET /status`, `GET /monitor`, `POST /monit issues-browser-tray --port 17365 -Windows system tray (Win32). Start `issuesd` first. Menu: status, sync now, open archives, quit. -Qt settings UI can wrap the same IPC later; tray icon uses a native shim because `dqt` lacks `QSystemTrayIcon` today. +Cross-platform system tray via Qt/`dqt` (`QSystemTrayIcon` from `dlang-supplemental/dqt`). Start `issuesd` first. Menu: status, sync now, open archives, quit. == GUI diff --git a/dub.json b/dub.json index 793077b..dab6fca 100644 --- a/dub.json +++ b/dub.json @@ -9,13 +9,13 @@ "dependencies": { "d2sqlite3": "~>1.0.0", "stdx-allocator": "~>3.0.2", - "sdlang-d": "~>0.10.6" + "sdlang-d": "~>0.10.6", + "repo-get": "~>0.3.0" }, "subConfigurations": { "d2sqlite3": "all-included" }, "copyFiles": [ - "profiles/forge-profiles.sdl", "help.sdl" ], "configurations": [ @@ -92,9 +92,15 @@ "excludedSourceFiles": [ "source/app_gui.d", "source/app_cli.d", - "source/app_daemon.d" + "source/app_daemon.d", + "source/tray/win_tray.d" ], - "libs-windows": ["user32", "shell32", "gdi32"], + "dependencies": { + "dqt:widgets": { + "repository": "git+https://github.com/dlang-supplemental/dqt.git", + "version": "297b81b2cb7e0e40dc0c7192252c4f150f735865" + } + }, "lflags-windows-x86_64": [ "/LIBPATH:$PACKAGE_DIR/vendor/sqlite/win64" ] diff --git a/source/app_tray.d b/source/app_tray.d index a694e22..8dcbf9b 100644 --- a/source/app_tray.d +++ b/source/app_tray.d @@ -1,4 +1,4 @@ -/** issues-browser-tray — system tray client for issuesd (Win32 tray; Qt settings later). */ +/** issues-browser-tray — system tray client for issuesd (Qt / dqt). */ module app_tray; import std.stdio; @@ -9,52 +9,67 @@ import std.net.curl; import std.process; import std.path; import std.file; -import tray.win_tray; +import tray.qt_tray; import issuesbrowser.paths; import issuesbrowser.ipc : defaultPort; -void main(string[] args) { +void main(string[] args) +{ string root; ushort port = defaultPort; getopt(args, "root", &root, "port", &port); auto archiveRootPath = archiveRoot(root); auto base = "http://127.0.0.1:" ~ to!string(port); - // Ensure daemon is up (best-effort) - try { + try + { get(base ~ "/health"); - } catch (Exception) { + } + catch (Exception) + { stderr.writeln("issuesd not reachable at ", base, " — start issuesd first."); } - version (Windows) { - if (!startTray("issues-browser", (uint cmd) { - try { - if (cmd == ID_SYNC) { - post(base ~ "/sync-now", `{"repo":""}`); - setTrayTip("Sync requested"); - } else if (cmd == ID_STATUS) { - auto st = get(base ~ "/status").idup; - setTrayTip(st.length > 120 ? st[0 .. 120] : st); - stderr.writeln(st); - } else if (cmd == ID_OPEN_ARCHIVES) { - auto dir = archivesDir(archiveRootPath); - mkdirRecurse(dir); + if (!startTray("issues-browser", (uint cmd) { + try + { + if (cmd == ID_SYNC) + { + post(base ~ "/sync-now", `{"repo":""}`); + setTrayTip("Sync requested"); + } + else if (cmd == ID_STATUS) + { + auto st = get(base ~ "/status").idup; + setTrayTip(st.length > 120 ? st[0 .. 120] : st); + stderr.writeln(st); + } + else if (cmd == ID_OPEN_ARCHIVES) + { + auto dir = archivesDir(archiveRootPath); + mkdirRecurse(dir); + version (Windows) spawnProcess(["explorer", dir]); - } else if (cmd == ID_QUIT) { - stopTray(); - } - } catch (Exception e) { - setTrayTip("Error: " ~ e.msg); + else version (OSX) + spawnProcess(["open", dir]); + else + spawnProcess(["xdg-open", dir]); } - })) { - stderr.writeln("Failed to create tray icon."); - return; + else if (cmd == ID_QUIT) + { + stopTray(); + } + } + catch (Exception e) + { + setTrayTip("Error: " ~ e.msg); } - stderr.writeln("Tray running. Archives: ", archiveRootPath); - runTrayLoop(); - } else { - stderr.writeln("Tray UI is Windows-first (Win32). Use CLI/daemon on this platform."); - stderr.writeln("IPC: ", base, " archives: ", archiveRootPath); + })) + { + stderr.writeln("Failed to create system tray (is a tray host available?)."); + return; } + + stderr.writeln("Tray running. Archives: ", archiveRootPath); + runTrayLoop(); } diff --git a/source/issuesbrowser/profiles.d b/source/issuesbrowser/profiles.d index c1562f3..85981f5 100644 --- a/source/issuesbrowser/profiles.d +++ b/source/issuesbrowser/profiles.d @@ -1,61 +1,67 @@ module issuesbrowser.profiles; -import std.file; -import std.path; -import std.string; -import std.algorithm; +import std.algorithm : canFind; import std.array; -import std.conv; -import std.regex; -import sdlang; +import std.string; import issuesbrowser.types; -import issuesbrowser.paths; +import rg = repoget.forge; private ForgeProfile[] gProfiles; private bool gLoaded; -void loadForgeProfiles(string root = null) { +private ForgeProfile toLocal(rg.ForgeProfile p) +{ + ForgeProfile outP; + outP.name = p.name; + outP.matchers = p.matchers.dup; + outP.cli = p.cli; + outP.listIssuesCmd = p.listIssuesCmd.dup; + outP.listPrsCmd = p.listPrsCmd.dup; + outP.graphql = p.graphql; + outP.minIntervalMs = p.minIntervalMs; + outP.on429BackoffS = p.on429BackoffS; + return outP; +} + +void loadForgeProfiles(string root = null) +{ gProfiles.length = 0; gLoaded = true; - string[] candidates; - try { - candidates ~= buildPath(dirName(thisExePath()), "profiles", "forge-profiles.sdl"); - } catch (Exception) {} - candidates ~= buildPath(getcwd(), "profiles", "forge-profiles.sdl"); - candidates ~= buildPath(archiveRoot(root), "forge-profiles.sdl"); - candidates ~= absolutePath(buildPath("profiles", "forge-profiles.sdl")); - foreach (c; candidates) { - if (exists(c)) { - parseForgeContent(readText(c)); - break; - } + + foreach (host; ["github.com", "gitlab.com"]) + { + auto p = rg.getForge(host); + if (p.name.length && !gProfiles.canFind!(x => x.name == p.name)) + gProfiles ~= toLocal(p); } + if (gProfiles.length == 0) gProfiles ~= defaultGitHub(); } -ForgeProfile[] allForgeProfiles() { - if (!gLoaded) loadForgeProfiles(); +ForgeProfile[] allForgeProfiles() +{ + if (!gLoaded) + loadForgeProfiles(); return gProfiles; } -ForgeProfile resolveForge(string host) { - if (!gLoaded) loadForgeProfiles(); +ForgeProfile resolveForge(string host) +{ + if (!gLoaded) + loadForgeProfiles(); auto h = host.length ? host : "github.com"; - foreach (p; gProfiles) { - foreach (m; p.matchers) { - try { - if (!matchFirst(h, regex(m)).empty) - return p; - } catch (Exception) {} - } - } + auto p = rg.getForge(h); + if (p.name.length) + return toLocal(p); return defaultGitHub(); } -string[] interpolateCmd(string[] tmpl, string owner, string name, string host, string since = "") { +string[] interpolateCmd(string[] tmpl, string owner, string name, string host, string since = "") +{ string[] outArgs; - foreach (a; tmpl) { + foreach (a; tmpl) + { outArgs ~= a .replace("$OWNER", owner) .replace("$NAME", name) @@ -65,48 +71,8 @@ string[] interpolateCmd(string[] tmpl, string owner, string name, string host, s return outArgs; } -private void parseForgeContent(string content) { - Tag root = parseSource(content); - foreach (tag; root.tags) { - if (tag.name != "forge") continue; - ForgeProfile p; - p.name = tag.values[0].get!string; - foreach (child; tag.tags) { - string[] vals; - foreach (v; child.values) vals ~= v.get!string; - switch (child.name) { - case "matcher": - p.matchers = vals; - break; - case "cli": - if (vals.length) p.cli = vals[0]; - break; - case "list-issues": - p.listIssuesCmd = vals; - break; - case "list-prs": - p.listPrsCmd = vals; - break; - case "graphql": - if (vals.length) p.graphql = vals[0] == "true" || child.values[0].get!bool; - break; - case "rate_limit": - foreach (rl; child.tags) { - if (rl.name == "min_interval_ms" && rl.values.length) - p.minIntervalMs = cast(int) rl.values[0].get!long; - else if (rl.name == "on_429_backoff_s" && rl.values.length) - p.on429BackoffS = cast(int) rl.values[0].get!long; - } - break; - default: - break; - } - } - gProfiles ~= p; - } -} - -private ForgeProfile defaultGitHub() { +private ForgeProfile defaultGitHub() +{ ForgeProfile p; p.name = "github"; p.matchers = ["github\\.com"]; diff --git a/source/tray/qt_tray.d b/source/tray/qt_tray.d new file mode 100644 index 0000000..0bca6c3 --- /dev/null +++ b/source/tray/qt_tray.d @@ -0,0 +1,117 @@ +/** Cross-platform system tray via dqt QSystemTrayIcon (dlang-supplemental/dqt fork). */ +module tray.qt_tray; + +import core.runtime; +import core.stdcpp.new_; + +import qt.config; +import qt.core.object; +import qt.core.string; +import qt.gui.action; +import qt.gui.icon; +import qt.helpers; +import qt.widgets.application; +import qt.widgets.menu; +import qt.widgets.style; +import qt.widgets.systemtrayicon; + +enum uint ID_SYNC = 1001; +enum uint ID_STATUS = 1002; +enum uint ID_QUIT = 1003; +enum uint ID_OPEN_ARCHIVES = 1004; + +alias TrayCallback = void delegate(uint cmd); + +private class TrayController : QObject +{ + mixin(Q_OBJECT_D); + + this(TrayCallback cb) + { + super(null); + this.cb = cb; + } + +private /+ slots +/: + @QSlot final void onStatus() + { + if (cb !is null) + cb(ID_STATUS); + } + + @QSlot final void onSync() + { + if (cb !is null) + cb(ID_SYNC); + } + + @QSlot final void onOpen() + { + if (cb !is null) + cb(ID_OPEN_ARCHIVES); + } + + @QSlot final void onQuit() + { + if (cb !is null) + cb(ID_QUIT); + } + +private: + TrayCallback cb; +} + +private QApplication gApp; +private QSystemTrayIcon gTray; +private TrayController gController; + +bool startTray(string tooltip, TrayCallback cb) +{ + int argc = Runtime.cArgs.argc; + char** argv = Runtime.cArgs.argv; + gApp = new QApplication(argc, argv); + + if (!QSystemTrayIcon.isSystemTrayAvailable()) + return false; + + gController = cpp_new!TrayController(cb); + + auto menu = cpp_new!QMenu(); + auto actStatus = menu.addAction(QString("Status")); + auto actSync = menu.addAction(QString("Sync now")); + auto actOpen = menu.addAction(QString("Open archives")); + menu.addSeparator(); + auto actQuit = menu.addAction(QString("Quit")); + + QObject.connect(actStatus.signal!"triggered", gController.slot!"onStatus"); + QObject.connect(actSync.signal!"triggered", gController.slot!"onSync"); + QObject.connect(actOpen.signal!"triggered", gController.slot!"onOpen"); + QObject.connect(actQuit.signal!"triggered", gController.slot!"onQuit"); + + gTray = cpp_new!QSystemTrayIcon(); + gTray.setIcon(gApp.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)); + gTray.setToolTip(QString(tooltip)); + gTray.setContextMenu(menu); + gTray.show(); + return true; +} + +void setTrayTip(string tip) +{ + if (gTray !is null) + gTray.setToolTip(QString(tip)); +} + +void runTrayLoop() +{ + if (gApp !is null) + gApp.exec(); +} + +void stopTray() +{ + if (gTray !is null) + gTray.hide(); + if (gApp !is null) + gApp.quit(); +}