diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..2328612 --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,42 @@ +name: CI Build (VSIX) + +# Compile l'extension VSIX sur un runner Windows cloud à chaque push. +# Sert de "compilateur distant" : permet de vérifier que le code compile +# sans avoir Visual Studio / Windows en local. +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build: + name: Build sur Windows + runs-on: windows-latest + defaults: + run: + working-directory: visualstudio-extension + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Setup NuGet + uses: NuGet/setup-nuget@v2 + + - name: Restore NuGet packages + run: nuget restore DevGlobe.sln + + - name: Build (Release, sans déploiement local) + run: msbuild DevGlobe.sln /p:Configuration=Release /p:DeployExtension=false /m /v:minimal + + - name: Upload du VSIX produit + if: success() + uses: actions/upload-artifact@v4 + with: + name: DevGlobe-vsix + path: '**/bin/Release/*.vsix' + if-no-files-found: warn diff --git a/.github/workflows/publish-visualstudio.yml b/.github/workflows/publish-visualstudio.yml new file mode 100644 index 0000000..06cb455 --- /dev/null +++ b/.github/workflows/publish-visualstudio.yml @@ -0,0 +1,60 @@ +name: Publish Visual Studio Extension (GitHub only) + +on: + push: + tags: + - "vs-v*" + workflow_dispatch: + +jobs: + publish: + runs-on: windows-latest + permissions: + contents: write + defaults: + run: + working-directory: visualstudio-extension + + steps: + - uses: actions/checkout@v4 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Setup NuGet + uses: nuget/setup-nuget@v2 + + - name: Restore NuGet packages + run: nuget restore DevGlobe.sln + + - name: Build VSIX (Release) + run: msbuild DevGlobe.sln /t:Rebuild /p:Configuration=Release /p:DeployExtension=false + + - name: Locate VSIX + id: vsix + shell: pwsh + run: | + $vsix = Get-ChildItem -Path . -Recurse -Filter *.vsix | + Where-Object { $_.FullName -match 'bin\\Release' } | + Select-Object -First 1 + if (-not $vsix) { + Write-Error "No .vsix found under bin\Release" + exit 1 + } + Write-Host "Found VSIX: $($vsix.FullName)" + "path=$($vsix.FullName)" >> $env:GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.vsix.outputs.path }} + body: | + ## DevGlobe for Visual Studio + + Built from commit ${{ github.sha }} + + ### Install manually + 1. Download the `.vsix` file below + 2. Double-click it (or use **Extensions → Manage Extensions → Install from VSIX…**) + 3. Restart Visual Studio + generate_release_notes: true diff --git a/PRIVACY.md b/PRIVACY.md index f7c0193..25d16d0 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -79,6 +79,7 @@ If your profile is set to `anonymous`, the server stores no precise coordinates |-----|---------| | VS Code | OS keychain via `SecretStorage` (macOS Keychain, Windows Credential Manager, Linux libsecret). Old plaintext entries in `settings.json` are migrated automatically. | | JetBrains | OS keychain via `PasswordSafe`. | +| Visual Studio | Windows Credential Manager. The `devglobe-core` daemon reads the key from `~/.devglobe/config.toml` (written with restrictive permissions). | | Zed, NeoVim, Claude Code, Codex, OpenCode | `~/.devglobe/config.toml`, written with `0600` permissions. | --- diff --git a/README.md b/README.md index 62626ba..9b7f611 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@

VS Code  ·  + Visual Studio  ·  JetBrains  ·  Zed  ·  NeoVim  ·  @@ -93,6 +94,43 @@ Available from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): --- +### Visual Studio + +The full **Visual Studio** IDE on Windows (not to be confused with VS Code). + +#### Installation + +1. Install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/) (search **DevGlobe**) or download the `.vsix` from the [Releases](https://github.com/Nako0/devglobe-extension/releases) and install it via **Extensions → Manage Extensions → Install from VSIX…** +2. Open the **DevGlobe** panel — click the **globe button** in the toolbar, or **View → Other Windows → DevGlobe** +3. Paste your API key → **Connect** + +On first launch, the extension downloads the matching `devglobe-core` binary for Windows from [GitHub Releases](https://github.com/Nako0/devglobe-extension/releases) (one-time) and caches it under `%LOCALAPPDATA%\DevGlobe\core`. + +#### Tool window + +- **Login** — masked API key field + link to get your key on devglobe.app +- **Dashboard** — live coding time, active language, status message, start/stop tracking, disconnect + +#### Commands + +Available under **Tools → DevGlobe**: + +| Command | Description | +|---|---| +| `Set Status Message` | Set your status message on the globe | +| `Show Coding Time` | Show your coding time today | +| `Open Globe` | Open [devglobe.app/space](https://devglobe.app/space) in your browser | +| `Debug` | Toggle debug logging in `~/.devglobe/devglobe.log` | +| `Open Log File…` | Open `~/.devglobe/devglobe.log` | +| `Open Config File…` | Open `~/.devglobe/config.toml` | + +#### Compatibility + +- **Visual Studio 2022 (17.x)** and **Visual Studio 2026 (18.x)** — Windows only +- .NET Framework 4.7.2 + +--- + ### JetBrains Compatible with **all JetBrains IDEs**: IntelliJ IDEA, WebStorm, PyCharm, GoLand, Rider, PhpStorm, CLion, RubyMine, DataGrip, Android Studio, RustRover. @@ -361,7 +399,7 @@ hide_project_names = false # omit repo + branch (project-level hiding implies **Globe visibility** (anonymous mode, repo sharing on the live globe, profile mode) is managed on [devglobe.app/dashboard/settings](https://devglobe.app/dashboard/settings) — not in the extension. -**API keys** are stored in your OS keychain (VS Code SecretStorage, JetBrains PasswordSafe) or in a local config file under `~/.devglobe/` (Zed, NeoVim, Claude Code, Codex, OpenCode). Config files are created with `0600` permissions. +**API keys** are stored in your OS keychain (VS Code SecretStorage, JetBrains PasswordSafe, Visual Studio Windows Credential Manager) or in a local config file under `~/.devglobe/` (Zed, NeoVim, Claude Code, Codex, OpenCode). Config files are created with `0600` permissions. **Network:** HTTPS only (TLS 1.2+), no telemetry, no third-party trackers. diff --git a/SECURITY.md b/SECURITY.md index 60856a5..42ad718 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,9 @@ Only the latest release of each extension receives security updates. | Extension | Current version | Status | |-----------|----------------|--------| -| VS Code | 0.1.8 | Supported | -| JetBrains | 0.1.7 | Supported | +| VS Code | 2.1.0 | Supported | +| JetBrains | 2.0.1 | Supported | +| Visual Studio | 0.1.0 | Supported | | Claude Code | 1.0.0 | Supported | ## Reporting a vulnerability @@ -78,7 +79,7 @@ Key design decisions: - **HTTPS only** — all network requests enforce TLS, no HTTP fallback - **Minimal data** — only what is listed in [PRIVACY.md](PRIVACY.md) is sent; source code, file contents, and keystrokes are never accessed -- **Secure key storage** — OS keychain on VS Code (SecretStorage) and JetBrains (PasswordSafe); `~/.devglobe/config.toml` (mode `0600`) on the other extensions +- **Secure key storage** — OS keychain on VS Code (SecretStorage), JetBrains (PasswordSafe) and Visual Studio (Windows Credential Manager); `~/.devglobe/config.toml` (mode `0600`) on the other extensions - **Content Security Policy** — VS Code webview uses a cryptographic nonce-based CSP - **No telemetry** — no third-party analytics or tracking services diff --git a/visualstudio-extension/.gitignore b/visualstudio-extension/.gitignore new file mode 100644 index 0000000..a829bd3 --- /dev/null +++ b/visualstudio-extension/.gitignore @@ -0,0 +1,21 @@ +# .NET / MSBuild build output +bin/ +obj/ + +# Visual Studio user / cache files +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# VSIX package output +*.vsix + +# ReSharper / Rider +_ReSharper*/ +*.DotSettings.user + +# Test results +[Tt]est[Rr]esult*/ +*.trx diff --git a/visualstudio-extension/CHANGELOG.md b/visualstudio-extension/CHANGELOG.md new file mode 100644 index 0000000..8019c86 --- /dev/null +++ b/visualstudio-extension/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to the DevGlobe Visual Studio extension are documented here. + +## [0.1.0] - 2026-06-23 + +### Added + +- Initial release of DevGlobe for Visual Studio (VS 2022 / VS 2026). +- DevGlobe tool window with **Login** and **Dashboard** views. +- Live heartbeat tracking driven by the `devglobe-core` binary (heartbeat every 30s, auto-pause after 1 min of inactivity). +- Activity detection: typing, active document, document open/save. +- Language detection from the active document. +- Status bar showing today's coding time. +- Six commands under **Tools → DevGlobe**: Set Status Message, Show Coding Time, Open Globe, Debug, Open Log File, Open Config File. +- API key stored in the Windows Credential Manager and written to `%USERPROFILE%\.devglobe\config.toml`. +- One-time download of the `devglobe-core-win-x64.exe` binary from GitHub Releases on first launch, cached under `%LOCALAPPDATA%\DevGlobe\core`. diff --git a/visualstudio-extension/DevGlobe.sln b/visualstudio-extension/DevGlobe.sln new file mode 100644 index 0000000..c12fd08 --- /dev/null +++ b/visualstudio-extension/DevGlobe.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevGlobe", "DevGlobe\DevGlobe.csproj", "{5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/visualstudio-extension/DevGlobe/ActivityTracker.cs b/visualstudio-extension/DevGlobe/ActivityTracker.cs new file mode 100644 index 0000000..e3707e9 --- /dev/null +++ b/visualstudio-extension/DevGlobe/ActivityTracker.cs @@ -0,0 +1,332 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; +using Microsoft.VisualStudio.Editor; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.TextManager.Interop; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe +{ + ///

+ /// Detects editor activity and forwards it to the core (which debounces). Sources are the + /// Running Document Table (open/show/save) and ITextBuffer.Changed (keystrokes) of the + /// active document. Only real on-disk files are forwarded. + /// + public sealed class ActivityTracker : IVsRunningDocTableEvents3, IDisposable + { + private readonly IServiceProvider _serviceProvider; + private readonly CoreClient _coreClient; + + private IVsRunningDocumentTable _rdt; + private IVsEditorAdaptersFactoryService _adapterFactory; + private uint _rdtCookie; + + // Buffer of the active document currently watched for keystrokes. + private ITextBuffer _activeBuffer; + private bool _disposed; + + public ActivityTracker(IServiceProvider serviceProvider, CoreClient coreClient) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _coreClient = coreClient ?? throw new ArgumentNullException(nameof(coreClient)); + } + + /// Subscribes to the RDT and captures the current active document. Call on the UI thread. + public async Task InitializeAsync(CancellationToken ct) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(ct); + + _rdt = _serviceProvider.GetService(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable; + if (_rdt == null) + { + Log.Error("ActivityTracker: SVsRunningDocumentTable unavailable"); + return; + } + + // IVsEditorAdaptersFactoryService is a MEF export from the editor component. + var componentModel = _serviceProvider.GetService(typeof(SComponentModel)) + as Microsoft.VisualStudio.ComponentModelHost.IComponentModel; + _adapterFactory = componentModel?.GetService(); + if (_adapterFactory == null) + { + Log.Warn("ActivityTracker: IVsEditorAdaptersFactoryService unavailable; keystroke tracking disabled"); + } + + int hr = _rdt.AdviseRunningDocTableEvents(this, out _rdtCookie); + ErrorHandler.ThrowOnFailure(hr); + Log.Info("ActivityTracker: subscribed to RDT", new { cookie = _rdtCookie }); + + // Capture the document already active at startup. + TrackActiveDocument(); + } + + // ---- IVsRunningDocTableEvents3 ---- + + // Document opened or active tab changed. + public int OnBeforeDocumentWindowShow(uint docCookie, int fFirstShow, IVsWindowFrame pFrame) + { + ThreadHelper.ThrowIfNotOnUIThread(); + HandleDocumentEvent(docCookie); + SubscribeToBuffer(docCookie); + return VSConstants.S_OK; + } + + // Document saved. + public int OnAfterSave(uint docCookie) + { + ThreadHelper.ThrowIfNotOnUIThread(); + HandleDocumentEvent(docCookie); + return VSConstants.S_OK; + } + + // Rename or attribute change: the moniker may change, so re-forward. + public int OnAfterAttributeChangeEx( + uint docCookie, + uint grfAttribs, + IVsHierarchy pHierOld, uint itemidOld, string pszMkDocumentOld, + IVsHierarchy pHierNew, uint itemidNew, string pszMkDocumentNew) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if ((grfAttribs & (uint)__VSRDTATTRIB.RDTA_MkDocument) != 0) + { + HandleDocumentEvent(docCookie); + } + return VSConstants.S_OK; + } + + // Unused RDT events. + public int OnAfterFirstDocumentLock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) + => VSConstants.S_OK; + public int OnBeforeLastDocumentUnlock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining) + => VSConstants.S_OK; + public int OnAfterAttributeChange(uint docCookie, uint grfAttribs) + => VSConstants.S_OK; + public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame pFrame) + => VSConstants.S_OK; + public int OnBeforeSave(uint docCookie) + => VSConstants.S_OK; + + /// + /// Forwards activity for an RDT document (open/show/save). Filters out non-files. + /// Language is derived from the buffer content-type if available, otherwise the extension. + /// + private void HandleDocumentEvent(uint docCookie) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + string moniker = GetMoniker(docCookie); + if (!IsRealFile(moniker)) return; + + ITextBuffer buffer = GetBufferForCookie(docCookie); + ReportActivity(moniker, buffer); + } + catch (Exception ex) + { + Log.Warn("ActivityTracker: HandleDocumentEvent failed", new { error = ex.Message }); + } + } + + /// + /// Forwards an activity to the core. Language comes from the content-type when a buffer + /// is available, otherwise from the file extension. The core debounces, so forward all. + /// + private void ReportActivity(string filePath, ITextBuffer buffer) + { + if (!IsRealFile(filePath)) return; + + string languageKey = buffer != null + ? buffer.ContentType?.TypeName + : Path.GetExtension(filePath); + + string language = string.IsNullOrEmpty(languageKey) + ? null + : LanguageMap.Map(languageKey); + + _coreClient.Activity(filePath, language); + } + + /// Returns true only for real on-disk files; non-file monikers are rejected. + private static bool IsRealFile(string moniker) + { + if (string.IsNullOrWhiteSpace(moniker)) return false; + // Non-file monikers are URIs/pseudo-paths (e.g. "ext://", "Solution.sln") or are not + // rooted absolute paths. Require a plausible absolute path. + try + { + if (moniker.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return false; + if (!Path.IsPathRooted(moniker)) return false; + // No URI scheme (file:// is already resolved to a path by the RDT; reject the rest). + if (moniker.Contains("://")) return false; + return true; + } + catch + { + return false; + } + } + + private string GetMoniker(uint docCookie) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_rdt == null) return null; + + int hr = _rdt.GetDocumentInfo( + docCookie, + out _, out _, out _, + out string moniker, + out _, out _, out _); + return ErrorHandler.Succeeded(hr) ? moniker : null; + } + + /// + /// Resolves the ITextBuffer for an RDT cookie via its IVsTextLines docData. Returns null + /// when the document is not a text editor (designer, etc.). + /// + private ITextBuffer GetBufferForCookie(uint docCookie) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_rdt == null || _adapterFactory == null) return null; + + int hr = _rdt.GetDocumentInfo( + docCookie, + out _, out _, out _, + out _, + out _, out _, + out IntPtr docDataPtr); + if (ErrorHandler.Failed(hr) || docDataPtr == IntPtr.Zero) return null; + + object docData = null; + try + { + docData = Marshal.GetObjectForIUnknown(docDataPtr); + if (docData is IVsTextLines textLines) + { + return _adapterFactory.GetDocumentBuffer(textLines); + } + if (docData is IVsTextBufferProvider bufferProvider + && ErrorHandler.Succeeded(bufferProvider.GetTextBuffer(out IVsTextLines lines)) + && lines != null) + { + return _adapterFactory.GetDocumentBuffer(lines); + } + return null; + } + finally + { + if (docDataPtr != IntPtr.Zero) Marshal.Release(docDataPtr); + } + } + + /// Captures the active document at startup and subscribes to it. + private void TrackActiveDocument() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var textManager = _serviceProvider.GetService(typeof(SVsTextManager)) as IVsTextManager; + if (textManager == null) return; + + int hr = textManager.GetActiveView(1, null, out IVsTextView activeView); + if (ErrorHandler.Failed(hr) || activeView == null) return; + if (ErrorHandler.Failed(activeView.GetBuffer(out IVsTextLines lines)) || lines == null) return; + if (_adapterFactory == null) return; + + ITextBuffer buffer = _adapterFactory.GetDocumentBuffer(lines); + if (buffer == null) return; + + SetActiveBuffer(buffer); + + // Forward an initial activity for the already-open document. + if (buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument textDoc) + && textDoc != null) + { + ReportActivity(textDoc.FilePath, buffer); + } + } + + /// Switches keystroke tracking to the ITextBuffer.Changed of the document for this cookie. + private void SubscribeToBuffer(uint docCookie) + { + ThreadHelper.ThrowIfNotOnUIThread(); + ITextBuffer buffer = GetBufferForCookie(docCookie); + if (buffer == null) return; + SetActiveBuffer(buffer); + } + + private void SetActiveBuffer(ITextBuffer buffer) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (ReferenceEquals(buffer, _activeBuffer)) return; + + if (_activeBuffer != null) + { + _activeBuffer.Changed -= OnActiveBufferChanged; + } + + _activeBuffer = buffer; + + if (_activeBuffer != null) + { + _activeBuffer.Changed += OnActiveBufferChanged; + } + } + + /// Keystroke in the active document. + private void OnActiveBufferChanged(object sender, TextContentChangedEventArgs e) + { + // ITextBuffer.Changed may fire off the UI thread depending on the source; stay defensive. + try + { + if (sender is ITextBuffer buffer + && buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument textDoc) + && textDoc != null) + { + ReportActivity(textDoc.FilePath, buffer); + } + } + catch (Exception ex) + { + Log.Warn("ActivityTracker: OnActiveBufferChanged failed", new { error = ex.Message }); + } + } + + // ---- Cleanup ---- + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + if (_activeBuffer != null) + { + _activeBuffer.Changed -= OnActiveBufferChanged; + _activeBuffer = null; + } + + if (_rdt != null && _rdtCookie != 0) + { + _rdt.UnadviseRunningDocTableEvents(_rdtCookie); + _rdtCookie = 0; + } + }); + } + catch (Exception ex) + { + Log.Warn("ActivityTracker: Dispose failed", new { error = ex.Message }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/CommandIds.cs b/visualstudio-extension/DevGlobe/Commands/CommandIds.cs new file mode 100644 index 0000000..d20da49 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/CommandIds.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// + /// CommandSet GUID and command IDs shared between the .vsct and the handlers. + /// Values must match the IDSymbol entries in DevGlobePackage.Commands.vsct exactly. + /// + internal static class CommandIds + { + public const string GuidDevGlobeCmdSetString = "7b5c1e40-9d36-40ac-bf8b-3c4d5e6f7081"; + public static readonly Guid GuidDevGlobeCmdSet = new Guid(GuidDevGlobeCmdSetString); + + public const int SetStatusCommandId = 0x0101; + public const int ShowCodingTimeCommandId = 0x0102; + public const int OpenGlobeCommandId = 0x0103; + public const int ToggleDebugCommandId = 0x0104; + public const int OpenLogFileCommandId = 0x0105; + public const int OpenConfigFileCommandId = 0x0106; + public const int OpenToolWindowCommandId = 0x0107; + } + + /// + /// Initializes all DevGlobe commands. Called once from DevGlobePackage.InitializeAsync. + /// + internal static class DevGlobeCommands + { + public static async Task InitializeAllAsync(AsyncPackage package, Func coreClientProvider) + { + if (package == null) throw new ArgumentNullException(nameof(package)); + if (coreClientProvider == null) throw new ArgumentNullException(nameof(coreClientProvider)); + + await SetStatusCommand.InitializeAsync(package, coreClientProvider); + await ShowCodingTimeCommand.InitializeAsync(package, coreClientProvider); + await OpenGlobeCommand.InitializeAsync(package); + await ToggleDebugCommand.InitializeAsync(package); + await OpenLogFileCommand.InitializeAsync(package); + await OpenConfigFileCommand.InitializeAsync(package); + await OpenToolWindowCommand.InitializeAsync(package); + + Log.Info("DevGlobe commands initialized", new { count = 7 }); + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs new file mode 100644 index 0000000..7d11713 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// Opens the config.toml file. + internal static class OpenConfigFileCommand + { + private static AsyncPackage _package; + + public static async Task InitializeAsync(AsyncPackage package) + { + _package = package ?? throw new ArgumentNullException(nameof(package)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("OpenConfigFileCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenConfigFileCommandId); + commandService.AddCommand(new OleMenuCommand(Execute, commandId)); + } + + private static void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var path = DevGlobeConfig.ConfigPath; + if (!File.Exists(path)) + { + VsShellUtilities.ShowMessageBox( + _package, + "DevGlobe: no config file yet. Run setup first (open the DevGlobe tool window and connect).", + "DevGlobe", + OLEMSGICON.OLEMSGICON_WARNING, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + return; + } + + OpenInEditor(path); + Log.Info("command openConfigFile", new { path }); + } + catch (Exception ex) + { + Log.Error("command openConfigFile failed", new { error = ex.Message }); + } + } + + private static void OpenInEditor(string path) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + VsShellUtilities.OpenDocument( + _package, path, + VSConstants.LOGVIEWID.Primary_guid, + out _, out _, out _); + } + catch (Exception ex) + { + Log.Warn("openConfigFile: in-IDE open failed, falling back to shell", new { error = ex.Message }); + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs new file mode 100644 index 0000000..2b78f82 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// Opens the globe in the default browser. + internal static class OpenGlobeCommand + { + private const string GlobeUrl = "https://devglobe.app/space"; + + public static async Task InitializeAsync(AsyncPackage package) + { + if (package == null) throw new ArgumentNullException(nameof(package)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("OpenGlobeCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenGlobeCommandId); + commandService.AddCommand(new OleMenuCommand(Execute, commandId)); + } + + private static void Execute(object sender, EventArgs e) + { + try + { + Process.Start(new ProcessStartInfo(GlobeUrl) { UseShellExecute = true }); + Log.Info("command openGlobe", new { url = GlobeUrl }); + } + catch (Exception ex) + { + Log.Error("command openGlobe failed", new { error = ex.Message }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs new file mode 100644 index 0000000..1aeb560 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// Opens the log file. + internal static class OpenLogFileCommand + { + private static AsyncPackage _package; + + public static async Task InitializeAsync(AsyncPackage package) + { + _package = package ?? throw new ArgumentNullException(nameof(package)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("OpenLogFileCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenLogFileCommandId); + commandService.AddCommand(new OleMenuCommand(Execute, commandId)); + } + + private static void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var path = DevGlobeConfig.LogPath; + if (!File.Exists(path)) + { + VsShellUtilities.ShowMessageBox( + _package, + "DevGlobe: log file is empty. Enable debug first (DevGlobe → Debug → Yes).", + "DevGlobe", + OLEMSGICON.OLEMSGICON_INFO, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + return; + } + + OpenInEditor(path); + Log.Info("command openLogFile", new { path }); + } + catch (Exception ex) + { + Log.Error("command openLogFile failed", new { error = ex.Message }); + } + } + + private static void OpenInEditor(string path) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + VsShellUtilities.OpenDocument( + _package, path, + VSConstants.LOGVIEWID.Primary_guid, + out _, out _, out _); + } + catch (Exception ex) + { + // Fall back to the external shell handler if the in-IDE open fails. + Log.Warn("openLogFile: in-IDE open failed, falling back to shell", new { error = ex.Message }); + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs new file mode 100644 index 0000000..bb57128 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel.Design; +using System.Threading.Tasks; +using DevGlobe.ToolWindow; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// + /// Opens (or reopens) the DevGlobe tool window, loading the package on first use if needed. + /// + internal static class OpenToolWindowCommand + { + public static async Task InitializeAsync(AsyncPackage package) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + + if (await package.GetServiceAsync(typeof(IMenuCommandService)) is OleMenuCommandService svc) + { + var id = new CommandID(CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenToolWindowCommandId); + svc.AddCommand(new MenuCommand((s, e) => Execute(package), id)); + } + } + + private static void Execute(AsyncPackage package) + { + _ = package.JoinableTaskFactory.RunAsync(async () => + { + await package.JoinableTaskFactory.SwitchToMainThreadAsync(); + await package.ShowToolWindowAsync( + typeof(DevGlobeToolWindow), id: 0, create: true, cancellationToken: package.DisposalToken); + }); + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs b/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs new file mode 100644 index 0000000..361ac81 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs @@ -0,0 +1,150 @@ +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// + /// Prompts for a status message (max 100 chars) and calls CoreClient.SetStatus. + /// + internal static class SetStatusCommand + { + private static Func _coreClientProvider; + + public static async Task InitializeAsync(AsyncPackage package, Func coreClientProvider) + { + _coreClientProvider = coreClientProvider ?? throw new ArgumentNullException(nameof(coreClientProvider)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (Microsoft.VisualStudio.Shell.OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("SetStatusCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.SetStatusCommandId); + var menuItem = new Microsoft.VisualStudio.Shell.OleMenuCommand(Execute, commandId); + commandService.AddCommand(menuItem); + } + + private static void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + // No API key configured: nothing to do. + if (string.IsNullOrEmpty(DevGlobeConfig.ReadApiKey())) + { + Log.Info("command setStatus skipped: no api key"); + return; + } + + var dialog = new StatusInputDialog(); + var owner = Application.Current?.MainWindow; + if (owner != null) + { + dialog.Owner = owner; + } + + var ok = dialog.ShowDialog(); + if (ok != true) + { + // Cancelled: do nothing. + return; + } + + var message = dialog.Message ?? string.Empty; + if (message.Length > 100) + { + message = message.Substring(0, 100); + } + + Log.Info("command setStatus", new { length = message.Length, hasKey = true }); + _coreClientProvider()?.SetStatus(message); + } + catch (Exception ex) + { + Log.Error("command setStatus failed", new { error = ex.Message }); + } + } + } + + /// + /// Modal WPF input dialog used by SetStatusCommand, limited to 100 characters. + /// + internal sealed class StatusInputDialog : Window + { + private readonly TextBox _input; + + /// Entered text (null if cancelled). + public string Message { get; private set; } + + public StatusInputDialog() + { + Title = "DevGlobe — Set Status Message"; + Width = 420; + Height = 170; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ResizeMode = ResizeMode.NoResize; + ShowInTaskbar = false; + + var root = new StackPanel { Margin = new Thickness(16) }; + + root.Children.Add(new TextBlock + { + Text = "What are you working on?", + Margin = new Thickness(0, 0, 0, 6) + }); + + _input = new TextBox + { + MaxLength = 100, + Margin = new Thickness(0, 0, 0, 4) + }; + _input.KeyDown += (s, e) => + { + if (e.Key == System.Windows.Input.Key.Enter) { Confirm(); } + else if (e.Key == System.Windows.Input.Key.Escape) { DialogResult = false; } + }; + root.Children.Add(_input); + + var counter = new TextBlock + { + Text = "0 / 100", + FontSize = 11, + Opacity = 0.7, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 0, 0, 10) + }; + _input.TextChanged += (s, e) => counter.Text = $"{_input.Text.Length} / 100"; + root.Children.Add(counter); + + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right + }; + var okButton = new Button { Content = "Set", Width = 80, IsDefault = true, Margin = new Thickness(0, 0, 8, 0) }; + okButton.Click += (s, e) => Confirm(); + var cancelButton = new Button { Content = "Cancel", Width = 80, IsCancel = true }; + buttons.Children.Add(okButton); + buttons.Children.Add(cancelButton); + root.Children.Add(buttons); + + Content = root; + Loaded += (s, e) => _input.Focus(); + } + + private void Confirm() + { + Message = _input.Text ?? string.Empty; + DialogResult = true; + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs b/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs new file mode 100644 index 0000000..21866ef --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// Shows today's coding time. + internal static class ShowCodingTimeCommand + { + private static Func _coreClientProvider; + private static AsyncPackage _package; + + public static async Task InitializeAsync(AsyncPackage package, Func coreClientProvider) + { + _package = package ?? throw new ArgumentNullException(nameof(package)); + _coreClientProvider = coreClientProvider ?? throw new ArgumentNullException(nameof(coreClientProvider)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("ShowCodingTimeCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.ShowCodingTimeCommandId); + commandService.AddCommand(new OleMenuCommand(Execute, commandId)); + } + + private static void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var client = _coreClientProvider(); + var codingTime = client != null ? client.GetState().CodingTime : "0m"; + if (string.IsNullOrEmpty(codingTime)) + { + codingTime = "0m"; + } + + VsShellUtilities.ShowMessageBox( + _package, + $"DevGlobe: {codingTime} today", + "DevGlobe", + OLEMSGICON.OLEMSGICON_INFO, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } + catch (Exception ex) + { + Log.Error("command showCodingTime failed", new { error = ex.Message }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs b/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs new file mode 100644 index 0000000..990e2ec --- /dev/null +++ b/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe.Commands +{ + /// Toggles debug mode in config.toml. + internal static class ToggleDebugCommand + { + // VSConstants.MessageBoxResult.IDYES value, inlined to avoid the dependency. + private const int IDYES = 6; + private static AsyncPackage _package; + + public static async Task InitializeAsync(AsyncPackage package) + { + _package = package ?? throw new ArgumentNullException(nameof(package)); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = (OleMenuCommandService) + await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService)); + if (commandService == null) + { + Log.Error("ToggleDebugCommand: IMenuCommandService unavailable"); + return; + } + + var commandId = new System.ComponentModel.Design.CommandID( + CommandIds.GuidDevGlobeCmdSet, CommandIds.ToggleDebugCommandId); + commandService.AddCommand(new OleMenuCommand(Execute, commandId)); + } + + private static void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var current = DevGlobeConfig.IsDebugEnabled(); + + var result = VsShellUtilities.ShowMessageBox( + _package, + $"Enable DevGlobe debug logging?\n\nCurrent value: {(current ? "enabled" : "disabled")}.\n" + + "Yes = enable, No = disable.", + "DevGlobe Debug", + OLEMSGICON.OLEMSGICON_QUERY, + OLEMSGBUTTON.OLEMSGBUTTON_YESNOCANCEL, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + + // Cancel or close: do nothing. + if (result != IDYES && result != /* IDNO */ 7) + { + return; + } + + var enabled = result == IDYES; + DevGlobeConfig.SetDebug(enabled); + Log.Info("command toggleDebug", new { enabled }); + + VsShellUtilities.ShowMessageBox( + _package, + $"DevGlobe: debug {(enabled ? "enabled" : "disabled")}. Restart tracking to apply.", + "DevGlobe", + OLEMSGICON.OLEMSGICON_INFO, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } + catch (Exception ex) + { + Log.Error("command toggleDebug failed", new { error = ex.Message }); + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/CoreBootstrap.cs b/visualstudio-extension/DevGlobe/CoreBootstrap.cs new file mode 100644 index 0000000..21444f0 --- /dev/null +++ b/visualstudio-extension/DevGlobe/CoreBootstrap.cs @@ -0,0 +1,244 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DevGlobe +{ + /// + /// Bootstraps the core binary, caching it at + /// %LOCALAPPDATA%\DevGlobe\core\<CoreTag>\devglobe-core-win-x64.exe. + /// If absent, resolves the pinned GitHub release, downloads the asset, writes the cache, + /// and purges old versions. + /// + public static class CoreBootstrap + { + public const string CoreRepo = "Nako0/devglobe-extension"; + // Pinned; bump manually when a new core is published. + public const string CoreTag = "core-v2.0.1"; + public const string AssetName = "devglobe-core-win-x64.exe"; + + // Cache subtree under %LOCALAPPDATA%. + private const string CacheVendorDir = "DevGlobe"; + private const string CacheCoreDir = "core"; + + // Combined timeout for download plus metadata; the binary is tens of MB. + private static readonly TimeSpan HttpTimeout = TimeSpan.FromMinutes(5); + + // Single reused HttpClient to avoid socket exhaustion. + private static readonly HttpClient Http = CreateHttpClient(); + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient { Timeout = HttpTimeout }; + // GitHub requires a User-Agent; Accept json for the releases API. + client.DefaultRequestHeaders.UserAgent.ParseAdd("DevGlobe-VisualStudio"); + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + return client; + } + + /// + /// Resolves the cache path <rootLocalAppData>\DevGlobe\core\<CoreTag>\<AssetName>. + /// + public static string ResolveCachePath(string rootLocalAppData) + { + if (string.IsNullOrEmpty(rootLocalAppData)) + throw new ArgumentException("rootLocalAppData must not be empty", nameof(rootLocalAppData)); + + return Path.Combine(rootLocalAppData, CacheVendorDir, CacheCoreDir, CoreTag, AssetName); + } + + /// + /// Ensures the core binary is cached and returns its absolute path, downloading from + /// GitHub Releases on first run. Throws with a clear message on failure. + /// + public static async Task EnsureBinaryAsync(CancellationToken ct) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localAppData)) + throw new InvalidOperationException("Could not resolve %LOCALAPPDATA% for the core cache."); + + var cachePath = ResolveCachePath(localAppData); + + // Fast path: already cached. + if (File.Exists(cachePath)) + { + Log.Info("CoreBootstrap: binary already cached", new { path = cachePath }); + return cachePath; + } + + Log.Info("CoreBootstrap: binary missing, downloading from GitHub", + new { repo = CoreRepo, tag = CoreTag, asset = AssetName }); + + var versionDir = Path.GetDirectoryName(cachePath)!; + Directory.CreateDirectory(versionDir); + + // Purge old versions before writing the new one (best-effort). + PurgeOldVersions(Path.Combine(localAppData, CacheVendorDir, CacheCoreDir), keepDir: versionDir); + + var downloadUrl = await ResolveAssetUrlAsync(ct).ConfigureAwait(false); + await DownloadToAsync(downloadUrl, cachePath, ct).ConfigureAwait(false); + + Log.Info("CoreBootstrap: binary ready", new { path = cachePath }); + return cachePath; + } + + /// + /// Queries the GitHub releases API for the pinned tag and returns the + /// browser_download_url of AssetName. + /// + private static async Task ResolveAssetUrlAsync(CancellationToken ct) + { + var apiUrl = $"https://api.github.com/repos/{CoreRepo}/releases/tags/{CoreTag}"; + + string json; + try + { + using var resp = await Http.GetAsync(apiUrl, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"GitHub API returned {(int)resp.StatusCode} {resp.ReasonPhrase} for release '{CoreTag}'."); + } + json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException) + { + throw new InvalidOperationException( + $"Failed to reach GitHub to resolve the DevGlobe core release '{CoreTag}'. " + + "Check your network connection and try again.", ex); + } + + string? downloadUrl = null; + try + { + var root = JObject.Parse(json); + if (root["assets"] is JArray assets) + { + foreach (var asset in assets) + { + if (string.Equals((string?)asset["name"], AssetName, StringComparison.Ordinal)) + { + downloadUrl = (string?)asset["browser_download_url"]; + break; + } + } + } + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Could not parse the GitHub release metadata for '{CoreTag}'.", ex); + } + + if (string.IsNullOrEmpty(downloadUrl)) + { + throw new InvalidOperationException( + $"Asset '{AssetName}' was not found in the GitHub release '{CoreTag}'."); + } + + return downloadUrl!; + } + + /// + /// Downloads the asset to a temp file then moves it into destPath, avoiding a + /// half-written binary if the IDE closes mid-download. + /// + private static async Task DownloadToAsync(string url, string destPath, CancellationToken ct) + { + var tmpPath = destPath + ".download"; + + try + { + using (var resp = await Http + .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false)) + { + if (!resp.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Download of '{AssetName}' failed with {(int)resp.StatusCode} {resp.ReasonPhrase}."); + } + + using var src = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var dst = new FileStream( + tmpPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + await src.CopyToAsync(dst, 81920, ct).ConfigureAwait(false); + } + + if (File.Exists(destPath)) + File.Delete(destPath); + File.Move(tmpPath, destPath); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + TryDelete(tmpPath); + throw; + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is IOException) + { + TryDelete(tmpPath); + throw new InvalidOperationException( + $"Failed to download the DevGlobe core binary from '{url}'. " + + "Check your network connection and try again.", ex); + } + } + + /// + /// Deletes version folders other than keepDir under the core cache dir (best-effort: + /// an error here must not fail the bootstrap). + /// + private static void PurgeOldVersions(string coreCacheDir, string keepDir) + { + try + { + if (!Directory.Exists(coreCacheDir)) + return; + + var keepFull = Path.GetFullPath(keepDir); + foreach (var dir in Directory.GetDirectories(coreCacheDir)) + { + if (string.Equals(Path.GetFullPath(dir), keepFull, StringComparison.OrdinalIgnoreCase)) + continue; + try + { + Directory.Delete(dir, recursive: true); + Log.Info("CoreBootstrap: purged old core version", new { dir }); + } + catch (Exception ex) + { + Log.Warn("CoreBootstrap: could not purge old core version", new { dir, error = ex.Message }); + } + } + } + catch (Exception ex) + { + Log.Warn("CoreBootstrap: purge step failed", new { error = ex.Message }); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // best-effort + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/CoreClient.cs b/visualstudio-extension/DevGlobe/CoreClient.cs new file mode 100644 index 0000000..8666a39 --- /dev/null +++ b/visualstudio-extension/DevGlobe/CoreClient.cs @@ -0,0 +1,428 @@ +using System; +using System.Diagnostics; +using System.Text; +using Newtonsoft.Json; + +namespace DevGlobe +{ + /// + /// Client for the devglobe-core daemon: launches "devglobe-core-win-x64.exe daemon", + /// communicates via JSON-lines over stdin/stdout, dispatches events to TrackerState + /// and onStateChange, and restarts the process if it dies. + /// + public sealed class CoreClient : IDisposable + { + private readonly string _corePath; + private readonly Action _onStateChange; + private readonly string _pluginVersion; + private readonly Action _onInvalidApiKey; + + // Optional UI hooks. No-op by default so CoreClient stays decoupled from the VSSDK. + private readonly Action _notifyInfo; + private readonly Action _notifyError; + private readonly Action _offerReconnect; + private DevGlobeStatusBar? _statusBar; + + private readonly object _gate = new object(); + private Process? _proc; + private TrackerState _state = NewDefaultState(); + private bool _disposed; + + public CoreClient( + string corePath, + Action onStateChange, + string pluginVersion, + Action onInvalidApiKey) + : this(corePath, onStateChange, pluginVersion, onInvalidApiKey, + statusBar: null, notifyInfo: null, notifyError: null, offerReconnect: null) + { + } + + /// Extended constructor that injects the status bar and UI hooks. + public CoreClient( + string corePath, + Action onStateChange, + string pluginVersion, + Action onInvalidApiKey, + DevGlobeStatusBar? statusBar, + Action? notifyInfo, + Action? notifyError, + Action? offerReconnect) + { + _corePath = corePath; + _onStateChange = onStateChange ?? (_ => { }); + _pluginVersion = pluginVersion; + _onInvalidApiKey = onInvalidApiKey ?? (() => { }); + _statusBar = statusBar; + _notifyInfo = notifyInfo ?? (_ => { }); + _notifyError = notifyError ?? (_ => { }); + _offerReconnect = offerReconnect ?? (() => { }); + } + + private static TrackerState NewDefaultState() => new TrackerState + { + Configured = false, + Tracking = false, + CodingTime = "0m", + TodaySeconds = 0, + Language = null, + Offline = false, + }; + + public TrackerState GetState() + { + lock (_gate) + { + // Defensive copy. + return new TrackerState + { + Configured = _state.Configured, + Tracking = _state.Tracking, + CodingTime = _state.CodingTime, + TodaySeconds = _state.TodaySeconds, + Language = _state.Language, + Offline = _state.Offline, + }; + } + } + + /// Serializes msg as JSON + '\n' and writes it to the daemon's stdin. + private void Send(object msg) + { + Process? proc; + lock (_gate) { proc = _proc; } + if (proc == null) return; + try + { + if (!proc.HasExited && proc.StandardInput.BaseStream.CanWrite) + { + string json = JsonConvert.SerializeObject(msg); + proc.StandardInput.Write(json); + proc.StandardInput.Write('\n'); + proc.StandardInput.Flush(); + } + } + catch (Exception ex) + { + Log.Warn("Failed to write to core stdin", new { error = ex.Message }); + } + } + + /// (Re)starts the daemon process if it is not running. + private void EnsureProcess() + { + lock (_gate) + { + if (_proc != null && !_proc.HasExited) return; + + var psi = new ProcessStartInfo + { + FileName = _corePath, + Arguments = "daemon", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + + Process proc; + try + { + proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; + proc.OutputDataReceived += OnOutputLine; + proc.ErrorDataReceived += OnErrorLine; + proc.Exited += OnProcessExited; + if (!proc.Start()) + { + HandleProcessFailure("Failed to start devglobe-core (Process.Start returned false)."); + return; + } + } + catch (Exception ex) + { + HandleProcessFailure($"Failed to start devglobe-core: {ex.Message}"); + return; + } + + _proc = proc; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + Log.Info("core daemon started", new { path = _corePath }); + } + } + + private void OnErrorLine(object sender, DataReceivedEventArgs e) + { + if (!string.IsNullOrEmpty(e.Data)) + { + Log.Warn("core stderr", new { line = e.Data }); + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + bool wasTracking; + lock (_gate) + { + int code = -1; + try { code = _proc?.ExitCode ?? -1; } catch { } + Log.Info("core exited", new { code }); + wasTracking = _state.Tracking; + _proc = null; + if (wasTracking) _state.Tracking = false; + } + + if (_disposed) return; + + if (wasTracking) + { + _onStateChange(GetState()); + _notifyError("Tracking stopped — devglobe-core exited unexpectedly."); + } + } + + private void HandleProcessFailure(string message) + { + Log.Error(message); + bool wasTracking; + lock (_gate) + { + _proc = null; + wasTracking = _state.Tracking; + if (wasTracking) _state.Tracking = false; + } + if (wasTracking) _onStateChange(GetState()); + _notifyError(message); + } + + private void OnOutputLine(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) return; // null signals the stream is closed + HandleLineResult result; + lock (_gate) + { + result = HandleLine(e.Data, _state); + } + ApplyEffects(result); + } + + /// + /// Pure logic: parses one JSON line, mutates the TrackerState and returns the side + /// effects to trigger. Touches neither the process nor the VSSDK UI. + /// + internal static HandleLineResult HandleLine(string line, TrackerState state) + { + var result = new HandleLineResult(); + if (string.IsNullOrWhiteSpace(line)) return result; + + CoreEvent? evt; + try + { + evt = JsonConvert.DeserializeObject(line); + } + catch (JsonException) + { + return result; // non-JSON line is ignored + } + if (evt?.Event == null) return result; + + switch (evt.Event) + { + case "ready": + state.Configured = evt.Data?.Configured ?? false; + result.StateChanged = true; + break; + + case "not_configured": + state.Configured = false; + state.Tracking = false; + result.StateChanged = true; + break; + + case "invalid_api_key": + state.Configured = false; + state.Tracking = false; + result.StateChanged = true; + result.InvalidApiKey = true; + break; + + case "heartbeat_ok": + state.TodaySeconds = evt.Data?.TodaySeconds ?? 0; + state.Language = evt.Data?.Language; + state.Tracking = true; + state.Offline = false; + result.UpdateStatusBar = true; + result.StatusBarSeconds = state.TodaySeconds; + result.StateChanged = true; + break; + + case "offline": + state.Offline = true; + result.StateChanged = true; + break; + + case "online": + state.Offline = false; + result.StateChanged = true; + break; + + case "status_ok": + result.StatusOk = true; + break; + + case "status_error": + result.StatusError = true; + result.StatusErrorMessage = evt.Data?.Message; + break; + + // unknown event is ignored (forward-compatible) + } + + return result; + } + + /// Executes the side effects described by HandleLine. + private void ApplyEffects(HandleLineResult result) + { + if (result.UpdateStatusBar) + { + _statusBar?.UpdateTime(result.StatusBarSeconds); + lock (_gate) + { + _state.CodingTime = FormatCodingTime(result.StatusBarSeconds); + } + } + + if (result.StateChanged) + { + _onStateChange(GetState()); + } + + if (result.InvalidApiKey) + { + Log.Warn("core reported invalid API key"); + _offerReconnect(); + _onInvalidApiKey(); + } + + if (result.StatusOk) + { + Log.Info("core status ok"); + _notifyInfo("Status updated"); + } + + if (result.StatusError) + { + Log.Warn("core status error", new { message = result.StatusErrorMessage }); + _notifyError(result.StatusErrorMessage ?? "Status update failed"); + } + } + + /// Formats seconds as "2h 15m" or "15m". + private static string FormatCodingTime(long todaySeconds) + { + long h = todaySeconds / 3600; + long m = (todaySeconds % 3600) / 60; + return h > 0 ? $"{h}h {m}m" : $"{m}m"; + } + + public void Init() + { + EnsureProcess(); + Send(new + { + method = "init", + @params = new + { + plugin_version = _pluginVersion, + editor = EditorInfo.DetectEditor(), + }, + }); + } + + public void Start() + { + lock (_gate) { _state.Tracking = true; } + Send(new { method = "resume" }); + _onStateChange(GetState()); + } + + public void Pause() + { + lock (_gate) { _state.Tracking = false; } + Send(new { method = "pause" }); + _statusBar?.Hide(); + _onStateChange(GetState()); + } + + public void Activity(string filePath, string? language) + { + if (string.IsNullOrEmpty(language)) + { + Send(new { method = "activity", @params = new { file = filePath } }); + } + else + { + Send(new { method = "activity", @params = new { file = filePath, language } }); + } + } + + public void SetStatus(string message) + { + Log.Info("core setStatus requested", new { length = message?.Length ?? 0 }); + Send(new { method = "set_status", @params = new { message } }); + } + + public void Reset() + { + TearDownProcess(); + lock (_gate) { _state = NewDefaultState(); } + _statusBar?.Hide(); + _onStateChange(GetState()); + } + + private void TearDownProcess() + { + Send(new { method = "shutdown" }); + Process? proc; + lock (_gate) + { + proc = _proc; + _proc = null; + } + if (proc == null) return; + try + { + proc.OutputDataReceived -= OnOutputLine; + proc.ErrorDataReceived -= OnErrorLine; + proc.Exited -= OnProcessExited; + if (!proc.HasExited) + { + // Allow a graceful shutdown, then kill. + if (!proc.WaitForExit(500)) + { + proc.Kill(); + } + } + } + catch (Exception ex) + { + Log.Warn("Error during core teardown", new { error = ex.Message }); + } + finally + { + proc.Dispose(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + TearDownProcess(); + } + } +} diff --git a/visualstudio-extension/DevGlobe/CoreEvents.cs b/visualstudio-extension/DevGlobe/CoreEvents.cs new file mode 100644 index 0000000..c1d7e1c --- /dev/null +++ b/visualstudio-extension/DevGlobe/CoreEvents.cs @@ -0,0 +1,55 @@ +using Newtonsoft.Json; + +namespace DevGlobe +{ + /// + /// Deserialization DTO for one daemon stdout line: { "event": "...", "data": { ... } }. + /// + internal sealed class CoreEvent + { + [JsonProperty("event")] + public string? Event { get; set; } + + [JsonProperty("data")] + public CoreEventData? Data { get; set; } + } + + internal sealed class CoreEventData + { + [JsonProperty("configured")] + public bool Configured { get; set; } + + [JsonProperty("today_seconds")] + public long TodaySeconds { get; set; } + + [JsonProperty("language")] + public string? Language { get; set; } + + [JsonProperty("message")] + public string? Message { get; set; } + } + + /// + /// Side effects for CoreClient to apply after HandleLine. HandleLine only mutates the + /// TrackerState and fills this descriptor; CoreClient then runs the effects. + /// + internal sealed class HandleLineResult + { + /// State changed: call onStateChange. + public bool StateChanged { get; set; } + + /// heartbeat_ok received: update the status bar with these seconds. + public bool UpdateStatusBar { get; set; } + public long StatusBarSeconds { get; set; } + + /// invalid_api_key received: warn the user and call onInvalidApiKey. + public bool InvalidApiKey { get; set; } + + /// status_ok received: notify the user. + public bool StatusOk { get; set; } + + /// status_error received: warn the user with this message. + public bool StatusError { get; set; } + public string? StatusErrorMessage { get; set; } + } +} diff --git a/visualstudio-extension/DevGlobe/DevGlobe.csproj b/visualstudio-extension/DevGlobe/DevGlobe.csproj new file mode 100644 index 0000000..d6fe7e8 --- /dev/null +++ b/visualstudio-extension/DevGlobe/DevGlobe.csproj @@ -0,0 +1,159 @@ + + + + + 17.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + Debug + AnyCPU + {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F} + + {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + Library + DevGlobe + DevGlobe + v4.7.2 + true + true + true + false + false + true + true + Program + $(DevEnvDir)devenv.exe + /rootsuffix Exp + latest + annotations + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MSBuild:Compile + Designer + + + DevGlobeToolWindowControl.xaml + + + MSBuild:Compile + Designer + + + LoginView.xaml + + + MSBuild:Compile + Designer + + + DashboardView.xaml + + + + + Designer + + + + + Menus.ctmenu + + + + true + VSPackage + + + + + true + + + + + 17.0.32112.339 + runtime + + + 17.0.5232 + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + 13.0.3 + + + + + \ No newline at end of file diff --git a/visualstudio-extension/DevGlobe/DevGlobeConfig.cs b/visualstudio-extension/DevGlobe/DevGlobeConfig.cs new file mode 100644 index 0000000..fbf735a --- /dev/null +++ b/visualstudio-extension/DevGlobe/DevGlobeConfig.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.RegularExpressions; + +namespace DevGlobe +{ + /// + /// Manages DevGlobe configuration: the ~/.devglobe paths, reading/writing config.toml + /// (api_key, debug, [privacy]) in a simple TOML format, and storing the secret in the + /// Windows Credential Manager. + /// + /// Parsing rule: api_key and debug are read/written only until a [section] header is + /// reached (`beforeSection`). + /// + public static class DevGlobeConfig + { + private const string TargetName = "DevGlobe:api_key"; + + // ---- Paths --------------------------------------------------------- + + /// %USERPROFILE%\.devglobe (HOME sur POSIX). + public static string DevGlobeDir => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".devglobe"); + + /// %USERPROFILE%\.devglobe\config.toml + public static string ConfigPath => Path.Combine(DevGlobeDir, "config.toml"); + + /// %USERPROFILE%\.devglobe\devglobe.log + public static string LogPath => Path.Combine(DevGlobeDir, "devglobe.log"); + + // ---- api_key ------------------------------------------------------- + + private static readonly Regex ApiKeyLine = + new Regex("^api_key\\s*=\\s*\"([^\"]*)\"", RegexOptions.Compiled); + + /// + /// Reads api_key from the section-less area of config.toml. Returns null if absent/empty. + /// + public static string? ReadApiKey() + { + if (!File.Exists(ConfigPath)) return null; + + var beforeSection = true; + foreach (var rawLine in File.ReadAllLines(ConfigPath)) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + if (!beforeSection) continue; + + var m = ApiKeyLine.Match(line); + if (m.Success) + { + var key = m.Groups[1].Value; + Log.Info("config api_key read", new { exists = true, length = key.Length }); + return string.IsNullOrEmpty(key) ? null : key; + } + } + + Log.Info("config api_key read", new { exists = false }); + return null; + } + + /// + /// Writes/replaces the section-less api_key, preserving the rest of the file. + /// + public static void WriteApiKey(string key) + { + EnsureDir(); + + var lines = ReadExistingLines(); + var updated = new List(); + var inserted = false; + var beforeSection = true; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + + if (beforeSection && line.StartsWith("api_key")) + { + updated.Add($"api_key = \"{key}\""); + inserted = true; + } + else + { + updated.Add(rawLine); + } + } + + if (!inserted) updated.Insert(0, $"api_key = \"{key}\""); + + WriteAllLinesRestricted(updated); + Log.Info("config api_key written", new { length = key.Length }); + } + + /// Removes the section-less api_key line. + public static void ClearApiKey() + { + if (!File.Exists(ConfigPath)) return; + + var updated = new List(); + var beforeSection = true; + foreach (var rawLine in ReadExistingLines()) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + if (beforeSection && line.StartsWith("api_key")) continue; + updated.Add(rawLine); + } + + WriteAllLinesRestricted(updated); + Log.Info("config api_key cleared"); + } + + // ---- debug --------------------------------------------------------- + + private static readonly Regex DebugLine = + new Regex("^debug\\s*=\\s*(true|false)", RegexOptions.Compiled); + + /// True if debug = true in the section-less area. + public static bool IsDebugEnabled() + { + if (!File.Exists(ConfigPath)) return false; + + var beforeSection = true; + foreach (var rawLine in File.ReadAllLines(ConfigPath)) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + if (!beforeSection) continue; + + var m = DebugLine.Match(line); + if (m.Success) return m.Groups[1].Value == "true"; + } + return false; + } + + /// + /// Enables/disables debug. When enabled, inserts "debug = true" (after api_key if present). + /// When disabled, removes any section-less debug line. + /// + public static void SetDebug(bool enabled) + { + EnsureDir(); + + var lines = ReadExistingLines(); + var updated = new List(); + var inserted = false; + var beforeSection = true; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + + if (beforeSection && line.StartsWith("debug")) + { + if (enabled) updated.Add("debug = true"); + // when disabled: omit the line (= default) + inserted = true; + } + else + { + updated.Add(rawLine); + } + } + + if (!inserted && enabled) + { + var apiKeyIdx = updated.FindIndex(l => l.Trim().StartsWith("api_key")); + if (apiKeyIdx >= 0) updated.Insert(apiKeyIdx + 1, "debug = true"); + else updated.Insert(0, "debug = true"); + } + + WriteAllLinesRestricted(updated); + } + + // ---- Internal TOML I/O -------------------------------------------- + + private static void EnsureDir() + { + if (!Directory.Exists(DevGlobeDir)) Directory.CreateDirectory(DevGlobeDir); + } + + private static IReadOnlyList ReadExistingLines() + { + if (!File.Exists(ConfigPath)) return Array.Empty(); + // Split on '\n' (not ReadAllLines) to preserve the content faithfully. + var content = File.ReadAllText(ConfigPath); + return content.Split('\n'); + } + + /// + /// Writes the lines, collapsing triple newlines, guarantees a trailing '\n', + /// then restricts permissions to the owner. + /// + private static void WriteAllLinesRestricted(IReadOnlyList lines) + { + EnsureDir(); + + var output = string.Join("\n", lines); + output = Regex.Replace(output, "\\n{3,}", "\n\n"); + if (!output.EndsWith("\n")) output += "\n"; + + File.WriteAllText(ConfigPath, output, new UTF8Encoding(false)); + RestrictToOwner(ConfigPath); + } + + /// + /// Windows equivalent of mode 0600: NTFS ACL without inheritance, granting full control + /// to the current owner only. On POSIX: chmod 600 via reflection, since + /// File.SetUnixFileMode does not exist on net472. + /// + private static void RestrictToOwner(string path) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var fileInfo = new FileInfo(path); + var security = fileInfo.GetAccessControl(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + var owner = WindowsIdentity.GetCurrent().User; + if (owner != null) + { + // Purge existing rules, then add the owner only. + var rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier)); + foreach (FileSystemAccessRule r in rules) + security.RemoveAccessRule(r); + + security.AddAccessRule(new FileSystemAccessRule( + owner, + FileSystemRights.FullControl, + AccessControlType.Allow)); + + fileInfo.SetAccessControl(security); + } + } + else + { + // POSIX: chmod 600 via reflection to stay compilable on net472. + var modeType = Type.GetType("System.IO.UnixFileMode"); + if (modeType != null) + { + var setMode = typeof(File).GetMethod("SetUnixFileMode", + new Type[] { typeof(string), modeType }); + if (setMode != null) + { + // 0600 = UserRead | UserWrite = 0x100 | 0x80 = 384. + var mode = Enum.ToObject(modeType, 384); + setMode.Invoke(null, new object[] { path, mode }); + } + } + } + } + catch + { + // Permission restriction is best-effort: it must never block the write. + } + } + + // ---- Windows Credential Manager (P/Invoke) ------------------------ + + private const int CRED_TYPE_GENERIC = 1; + private const int CRED_PERSIST_LOCAL_MACHINE = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public int Flags; + public int Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredWrite([In] ref CREDENTIAL credential, [In] uint flags); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredDelete(string target, int type, int flags); + + [DllImport("advapi32.dll", EntryPoint = "CredFree")] + private static extern void CredFree([In] IntPtr buffer); + + /// Stores (or replaces) the API key in the Windows Credential Manager. + public static void StoreSecret(string key) + { + var blob = Encoding.Unicode.GetBytes(key ?? string.Empty); + var blobPtr = Marshal.AllocHGlobal(blob.Length); + var targetPtr = Marshal.StringToHGlobalUni(TargetName); + try + { + Marshal.Copy(blob, 0, blobPtr, blob.Length); + var cred = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = targetPtr, + CredentialBlobSize = blob.Length, + CredentialBlob = blobPtr, + Persist = CRED_PERSIST_LOCAL_MACHINE, + AttributeCount = 0, + Attributes = IntPtr.Zero, + Comment = IntPtr.Zero, + TargetAlias = IntPtr.Zero, + UserName = IntPtr.Zero, + }; + + if (!CredWrite(ref cred, 0)) + { + var err = Marshal.GetLastWin32Error(); + Log.Warn("CredWrite failed", new { error = err }); + } + } + finally + { + Marshal.FreeHGlobal(blobPtr); + Marshal.FreeHGlobal(targetPtr); + } + } + + /// Reads the API key from the Credential Manager, or null if absent. + public static string? GetSecret() + { + if (!CredRead(TargetName, CRED_TYPE_GENERIC, 0, out var credPtr)) + return null; + + try + { + var cred = Marshal.PtrToStructure(credPtr); + if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) + return null; + + var bytes = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize); + return Encoding.Unicode.GetString(bytes); + } + finally + { + CredFree(credPtr); + } + } + + /// Removes the API key from the Credential Manager (no-op if absent). + public static void DeleteSecret() + { + if (!CredDelete(TargetName, CRED_TYPE_GENERIC, 0)) + { + var err = Marshal.GetLastWin32Error(); + // 1168 = ERROR_NOT_FOUND: already absent, non-fatal. + if (err != 1168) Log.Warn("CredDelete failed", new { error = err }); + } + } + + // ---- tracking_enabled ---------------------------------------------- + + /// + /// Reads tracking_enabled from config.toml (root section). Absent means true. + /// Same parsing as ReadApiKey/IsDebugEnabled (stops at the first section). + /// + public static bool IsTrackingEnabled() + { + if (!File.Exists(ConfigPath)) return true; + + var beforeSection = true; + foreach (var rawLine in File.ReadAllLines(ConfigPath)) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + if (!beforeSection) continue; + + var m = Regex.Match(line, @"^tracking_enabled\s*=\s*(true|false)"); + if (m.Success) return m.Groups[1].Value == "true"; + } + return true; + } + + /// + /// Writes/removes tracking_enabled in config.toml. When the value is the default (true) + /// the line is omitted; when false it is written explicitly. Inserted just after api_key + /// if present, otherwise at the top. + /// + public static void SetTrackingEnabled(bool enabled) + { + Directory.CreateDirectory(DevGlobeDir); + + var content = File.Exists(ConfigPath) ? File.ReadAllText(ConfigPath) : string.Empty; + var lines = content.Split('\n'); + var updated = new List(); + var beforeSection = true; + var handled = false; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.StartsWith("[")) beforeSection = false; + + if (beforeSection && line.StartsWith("tracking_enabled")) + { + // false => write the line; true (default) => omit it. + if (!enabled) updated.Add("tracking_enabled = false"); + handled = true; + } + else + { + updated.Add(rawLine); + } + } + + if (!handled && !enabled) + { + var apiKeyIdx = updated.FindIndex(l => l.Trim().StartsWith("api_key")); + if (apiKeyIdx >= 0) updated.Insert(apiKeyIdx + 1, "tracking_enabled = false"); + else updated.Insert(0, "tracking_enabled = false"); + } + + var output = Regex.Replace(string.Join("\n", updated), @"\n{3,}", "\n\n"); + if (!output.EndsWith("\n")) output += "\n"; + File.WriteAllText(ConfigPath, output); + Log.Info("config tracking_enabled written", new { enabled }); + } + } +} diff --git a/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs b/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs new file mode 100644 index 0000000..fc58590 --- /dev/null +++ b/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs @@ -0,0 +1,137 @@ +using System; +using System.Windows.Threading; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Imaging; +using Microsoft.VisualStudio.Imaging.Interop; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace DevGlobe +{ + /// + /// User notifications via the native VS info bar (IVsInfoBar). Info notifications auto-dismiss + /// after a few seconds; warnings and errors stay until closed manually. The invalid-key error + /// carries a "Get API key" action link. + /// + internal static class DevGlobeNotifications + { + private static IServiceProvider _serviceProvider; + private static IVsInfoBarUIFactory _factory; + private static IVsInfoBarHost _host; + private static readonly TimeSpan InfoAutoDismiss = TimeSpan.FromSeconds(5); + + /// + /// Call during package initialization. The host is not resolved here because the main window + /// (and thus its info bar host) does not exist yet at package load; the factory and host are + /// resolved lazily on first display. + /// + public static void Initialize(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// Resolves and caches the info bar factory and host. Must run on the UI thread. + private static bool EnsureHost() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_serviceProvider == null) return false; + + if (_factory == null) + _factory = _serviceProvider.GetService(typeof(SVsInfoBarUIFactory)) as IVsInfoBarUIFactory; + + if (_host == null) + { + var shell = _serviceProvider.GetService(typeof(SVsShell)) as IVsShell; + if (shell != null && + ErrorHandler.Succeeded(shell.GetProperty( + (int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out object hostObj))) + { + _host = hostObj as IVsInfoBarHost; + } + } + + return _factory != null && _host != null; + } + + public static void Info(string message) + => Show(message, KnownMonikers.StatusInformation, autoDismiss: true); + + public static void Warning(string message) + => Show(message, KnownMonikers.StatusWarning, autoDismiss: false); + + public static void Error(string message) + => Show(message, KnownMonikers.StatusError, autoDismiss: false); + + /// Error with an action link (e.g. "Get API key"). + public static void ErrorWithAction(string message, string actionText, Action onAction) + => Show(message, KnownMonikers.StatusError, autoDismiss: false, actionText, onAction); + + private static void Show(string message, ImageMoniker icon, bool autoDismiss, + string actionText = null, Action onAction = null) + { + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + try + { + if (!EnsureHost()) + { + Log.Info("notify (no info bar host)", new { message }); + return; + } + + InfoBarModel model = actionText != null + ? new InfoBarModel( + new[] { new InfoBarTextSpan(message) }, + new[] { new InfoBarHyperlink(actionText) }, + icon, isCloseButtonVisible: true) + : new InfoBarModel(message, icon, isCloseButtonVisible: true); + + var ui = _factory.CreateInfoBar(model); + var sink = new InfoBarEvents(onAction); + ui.Advise(sink, out uint cookie); + sink.Attach(cookie); + _host.AddInfoBar(ui); + + if (autoDismiss) + { + var timer = new DispatcherTimer { Interval = InfoAutoDismiss }; + timer.Tick += (s, e) => + { + timer.Stop(); + try { ui.Close(); } catch { /* already closed */ } + }; + timer.Start(); + } + } + catch (Exception ex) + { + Log.Warn("DevGlobeNotifications: show failed", new { error = ex.Message }); + } + }); + } + + /// Info bar event sink: runs the action, then unsubscribes on close. + private sealed class InfoBarEvents : IVsInfoBarUIEvents + { + private readonly Action _onAction; + private uint _cookie; + + public InfoBarEvents(Action onAction) => _onAction = onAction; + + public void Attach(uint cookie) => _cookie = cookie; + + public void OnClosed(IVsInfoBarUIElement infoBarUIElement) + { + try { infoBarUIElement.Unadvise(_cookie); } catch { /* best-effort */ } + } + + public void OnActionItemClicked(IVsInfoBarUIElement infoBarUIElement, IVsInfoBarActionItem actionItem) + { + try { _onAction?.Invoke(); } + catch (Exception ex) { Log.Warn("notify action failed", new { error = ex.Message }); } + try { infoBarUIElement.Close(); } catch { /* best-effort */ } + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/DevGlobePackage.cs b/visualstudio-extension/DevGlobe/DevGlobePackage.cs new file mode 100644 index 0000000..a301d3d --- /dev/null +++ b/visualstudio-extension/DevGlobe/DevGlobePackage.cs @@ -0,0 +1,414 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using DevGlobe.Commands; +using DevGlobe.ToolWindow; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Threading; // provides GetAwaiter for `await TaskScheduler.Default` +using Task = System.Threading.Tasks.Task; + +namespace DevGlobe +{ + /// Fixed integration GUIDs shared by the manifest, the .vsct, and the VSSDK components. + public static class DevGlobeGuids + { + public const string PackageGuidString = "5f3a9c2e-7b14-4e8a-9d6f-1a2b3c4d5e6f"; + public const string ToolWindowGuidString = "6a4b0d3f-8c25-4f9b-ae7a-2b3c4d5e6f70"; + public const string CommandSetGuidString = "7b5c1e40-9d36-40ac-bf8b-3c4d5e6f7081"; + + public static readonly Guid CommandSet = new Guid(CommandSetGuidString); + } + + /// + /// In-proc VSSDK entry point. Registers commands before any network work, then bootstraps the + /// core binary best-effort (never blocking UI registration). The tool window wires to the + /// CoreClient via , which survives VS recreating the window. + /// + [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] + [ProvideAutoLoad(VSConstants.UICONTEXT.NoSolution_string, PackageAutoLoadFlags.BackgroundLoad)] + [ProvideAutoLoad(VSConstants.UICONTEXT.SolutionExists_string, PackageAutoLoadFlags.BackgroundLoad)] + [ProvideToolWindow(typeof(DevGlobeToolWindow))] + [ProvideMenuResource("Menus.ctmenu", 2)] + [Guid(DevGlobeGuids.PackageGuidString)] + public sealed class DevGlobePackage : AsyncPackage + { + private CoreClient _coreClient; + private ActivityTracker _activityTracker; + private DevGlobeStatusBar _statusBar; + + private const string PluginVersion = "0.1.0"; // must match source.extension.vsixmanifest + + /// + /// Returns this package as the async factory for the tool window GUID. Required for + /// ShowToolWindowAsync(create:true) on an AsyncPackage; without it creation fails and + /// ShowToolWindowAsync returns null. + /// + public override IVsAsyncToolWindowFactory GetAsyncToolWindowFactory(Guid toolWindowType) + => toolWindowType.Equals(new Guid(DevGlobeToolWindow.WindowGuidString)) ? this : null; + + /// Title shown while the tool window is created asynchronously. + protected override string GetToolWindowTitle(Type toolWindowType, int id) + => toolWindowType == typeof(DevGlobeToolWindow) + ? "DevGlobe" + : base.GetToolWindowTitle(toolWindowType, id); + + /// + /// Async initialization. Commands (network-independent) are registered before the + /// best-effort core bootstrap. Wrapped in try/catch so a failure does not surface as an + /// opaque "SetSite failed" that blacklists the package. + /// + protected override async Task InitializeAsync( + CancellationToken cancellationToken, + IProgress progress) + { + await base.InitializeAsync(cancellationToken, progress); + + try + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + Log.RefreshLevel(); + Log.Info("DevGlobe activating…"); + + // User notifications via the VS info bar. + DevGlobeNotifications.Initialize(this); + + // Wire shell callbacks first: VS may create or restore the tool window at any time + // and it reads from the shell. + DevGlobeShell.Instance.OnConnect = OnConnectAsync; + DevGlobeShell.Instance.OnDisconnect = OnDisconnectAsync; + DevGlobeShell.Instance.OnStart = OnStartAsync; + DevGlobeShell.Instance.OnStop = OnStopAsync; + + // Register commands independently of the network. + await DevGlobeCommands.InitializeAllAsync(this, () => _coreClient); + + // Resolve and migrate the API key. + var apiKey = ResolveAndMigrateApiKey(); + + var statusbarSvc = await GetServiceAsync(typeof(SVsStatusbar)) as IVsStatusbar; + _statusBar = statusbarSvc != null ? new DevGlobeStatusBar(statusbarSvc) : null; + + // Bootstrap the core binary best-effort, on a background task. An auto-load package + // must never await a network operation (the core is ~114 MB) on its load path, or + // the IDE appears frozen during the download on first launch. Commands and the shell + // are already in place; StartCoreAsync wires the core once the binary is ready. + // DisposalToken keeps the task alive past the end of InitializeAsync. + _ = JoinableTaskFactory.RunAsync(async () => + { + // Leave the init path immediately. Since the work below can complete synchronously + // (cached binary, already on the UI thread), RunAsync would otherwise execute this + // delegate inline during InitializeAsync and delay package initialization. + await TaskScheduler.Default; + + string corePath = null; + try + { + corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken); + Log.Info("DevGlobe core binary ready", new { corePath }); + } + catch (Exception ex) + { + Log.Error("DevGlobe: core binary unavailable (degraded mode)", new { error = ex.Message }); + } + + await JoinableTaskFactory.SwitchToMainThreadAsync(); + if (corePath != null) + { + await StartCoreAsync(corePath, apiKey, DisposalToken); + } + else + { + // Degraded mode: no client, but commands and shell.OnConnect are in place. + DevGlobeShell.Instance.RaiseStateChanged(new TrackerState()); + } + }); + + // Show the DevGlobe toolbar once on first launch (custom toolbars are hidden by + // default in VS); afterwards VS remembers the user's choice. Deferred so the init + // path does not touch the shell (DTE/CommandBars). + _ = JoinableTaskFactory.RunAsync(async () => + { + await TaskScheduler.Default; + await JoinableTaskFactory.SwitchToMainThreadAsync(); + EnsureToolbarShownOnce(); + }); + + // The panel is not opened automatically at startup; the user opens it via the + // DevGlobe toolbar button or View > Other Windows > DevGlobe. VS restores the tool + // window if it was docked in the previous session. + Log.Info("DevGlobe activated."); + } + catch (Exception ex) + { + Log.Error("DevGlobe: InitializeAsync failed", new { error = ex.ToString() }); + } + } + + /// Creates the CoreClient and ActivityTracker, then starts tracking per config. + private async Task StartCoreAsync(string corePath, string apiKey, CancellationToken ct) + { + _coreClient = new CoreClient( + corePath, + state => UpdateToolWindowState(state), + PluginVersion, + OnInvalidApiKey, + statusBar: null, // status bar is handled at the package level (UpdateToolWindowState). + notifyInfo: msg => DevGlobeNotifications.Info("DevGlobe: " + msg), + notifyError: msg => DevGlobeNotifications.Error("DevGlobe: " + msg), + offerReconnect: OfferReconnect); + DevGlobeShell.Instance.Client = _coreClient; + + _activityTracker = new ActivityTracker(this, _coreClient); + await _activityTracker.InitializeAsync(ct); + Log.Info("DevGlobe: ActivityTracker initialized"); + + var trackingEnabled = DevGlobeConfig.IsTrackingEnabled(); + if (!string.IsNullOrEmpty(apiKey) && trackingEnabled) + { + _coreClient.Init(); + _coreClient.Start(); + } + else if (!string.IsNullOrEmpty(apiKey)) + { + _coreClient.Init(); + UpdateToolWindowState(_coreClient.GetState()); + } + else + { + UpdateToolWindowState(_coreClient.GetState()); + } + } + + private string ResolveAndMigrateApiKey() + { + var configKey = DevGlobeConfig.ReadApiKey(); + if (!string.IsNullOrEmpty(configKey)) + { + Log.Info("desktop api key resolved from config.toml", new { length = configKey.Length }); + DevGlobeConfig.StoreSecret(configKey); + return configKey; + } + + var stored = DevGlobeConfig.GetSecret(); + if (!string.IsNullOrEmpty(stored)) + { + Log.Info("desktop api key resolved from secret store", new { length = stored.Length }); + DevGlobeConfig.WriteApiKey(stored); + return stored; + } + + Log.Info("desktop api key resolved: none"); + return string.Empty; + } + + private void OnInvalidApiKey() + { + DevGlobeConfig.DeleteSecret(); + DevGlobeConfig.ClearApiKey(); + Log.Info("API key cleared after server rejected it (401)"); + } + + /// + /// Updates the status bar and broadcasts state via the shell (the tool window subscribes). + /// May be called from CoreClient.onStateChange on a non-UI thread, so work is marshalled + /// onto the UI thread. + /// + private void UpdateToolWindowState(TrackerState state) + { + _ = JoinableTaskFactory.RunAsync(async () => + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + + if (_statusBar != null) + { + if (state.Tracking) _statusBar.UpdateTime(state.TodaySeconds); + else _statusBar.Hide(); + } + + DevGlobeShell.Instance.RaiseStateChanged(state); + }); + } + + /// + /// Connect from the Login view. Writes the key to both stores, then starts the core + /// (bootstrapping the binary on demand if running in degraded mode). + /// + private async Task OnConnectAsync(string rawKey) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + + var token = (rawKey ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(token)) + { + Log.Warn("desktop token empty, ignored"); + DevGlobeNotifications.Error("DevGlobe: API key is empty."); + return; + } + + Log.Info("desktop token saved from tool window", new { length = token.Length }); + DevGlobeConfig.StoreSecret(token); + DevGlobeConfig.WriteApiKey(token); + DevGlobeConfig.SetTrackingEnabled(true); + + if (_coreClient == null) + { + try + { + var corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken); + await StartCoreAsync(corePath, token, DisposalToken); + DevGlobeNotifications.Info("DevGlobe: Connected!"); + return; // StartCoreAsync already performed Init/Start. + } + catch (Exception ex) + { + Log.Error("DevGlobe: failed to start core on connect", new { error = ex.Message }); + DevGlobeNotifications.Error("DevGlobe: failed to start tracking."); + return; + } + } + + _coreClient.Init(); + _coreClient.Start(); + UpdateToolWindowState(_coreClient.GetState()); + DevGlobeNotifications.Info("DevGlobe: Connected!"); + } + + /// Disconnect: clears the key from both stores and resets the core. + private async Task OnDisconnectAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + + DevGlobeConfig.DeleteSecret(); + DevGlobeConfig.ClearApiKey(); + _coreClient?.Reset(); + UpdateToolWindowState(new TrackerState()); + Log.Info("DevGlobe disconnected"); + DevGlobeNotifications.Info("DevGlobe: Disconnected."); + } + + /// + /// Start tracking from the Dashboard: requires a key, persists trackingEnabled=true, + /// (re)inits and starts, then notifies. + /// + private async Task OnStartAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + + var apiKey = DevGlobeConfig.ReadApiKey(); + if (string.IsNullOrEmpty(apiKey)) + { + // No key: do nothing (and no notification). + Log.Info("start tracking skipped: no api key"); + return; + } + + DevGlobeConfig.SetTrackingEnabled(true); + + if (_coreClient == null) + { + try + { + var corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken); + await StartCoreAsync(corePath, apiKey, DisposalToken); + } + catch (Exception ex) + { + Log.Error("DevGlobe: failed to start core on startTracking", new { error = ex.Message }); + DevGlobeNotifications.Error("DevGlobe: failed to start tracking."); + return; + } + } + else + { + _coreClient.Init(); + _coreClient.Start(); + } + + DevGlobeNotifications.Info("DevGlobe: Tracking started."); + } + + /// + /// Stop tracking from the Dashboard: persists trackingEnabled=false, pauses, then notifies. + /// + private async Task OnStopAsync() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + + DevGlobeConfig.SetTrackingEnabled(false); + _coreClient?.Pause(); + UpdateToolWindowState(_coreClient?.GetState() ?? new TrackerState()); + Log.Info("DevGlobe tracking stopped"); + DevGlobeNotifications.Info("DevGlobe: Tracking stopped."); + } + + /// + /// API key rejected (401): shows an error with a "Get API key" action that opens the + /// dashboard settings page. + /// + private void OfferReconnect() + { + DevGlobeNotifications.ErrorWithAction( + "DevGlobe: invalid API key. Please reconnect with a valid key.", + "Get API key", + () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "https://devglobe.app/dashboard/settings") { UseShellExecute = true }); + } + catch (Exception ex) + { + Log.Warn("OfferReconnect: open browser failed", new { error = ex.Message }); + } + }); + } + + /// + /// Shows the DevGlobe toolbar on the first launch only (tracked by a marker file in + /// ~/.devglobe). Custom toolbars are hidden by default in VS; this surfaces the panel once, + /// then respects the user's later choice. + /// + private void EnsureToolbarShownOnce() + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var marker = System.IO.Path.Combine(DevGlobeConfig.DevGlobeDir, ".vs_toolbar_initialized"); + if (System.IO.File.Exists(marker)) return; + + if (GetService(typeof(SDTE)) is EnvDTE.DTE dte) + { + dynamic commandBars = dte.CommandBars; + dynamic bar = commandBars["DevGlobe"]; + if (bar != null) + { + bar.Visible = true; + Log.Info("DevGlobe toolbar shown (first run)"); + } + } + + System.IO.Directory.CreateDirectory(DevGlobeConfig.DevGlobeDir); + System.IO.File.WriteAllText(marker, "1"); + } + catch (Exception ex) + { + Log.Warn("DevGlobe: could not show toolbar on first run", new { error = ex.Message }); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activityTracker?.Dispose(); + _coreClient?.Dispose(); + _statusBar?.Hide(); + } + base.Dispose(disposing); + } + } +} diff --git a/visualstudio-extension/DevGlobe/DevGlobeShell.cs b/visualstudio-extension/DevGlobe/DevGlobeShell.cs new file mode 100644 index 0000000..de1fb9d --- /dev/null +++ b/visualstudio-extension/DevGlobe/DevGlobeShell.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace DevGlobe +{ + /// + /// Shared package state accessible to the tool window regardless of its lifecycle. VS may + /// recreate the tool window via its parameterless constructor (layout restore, reopen) without + /// going through the package, so this singleton holds the callbacks and survives those + /// recreations: the package fills it in InitializeAsync, and the tool window consumes it in its + /// constructor and subscribes to . + /// + public sealed class DevGlobeShell + { + public static DevGlobeShell Instance { get; } = new DevGlobeShell(); + + private DevGlobeShell() { } + + /// Current daemon client (recreated after reset/reconnect, null in degraded mode). + public CoreClient Client { get; set; } + + /// Connect from the Login view: writes config and (re)inits and starts. + public Func OnConnect { get; set; } + + /// Disconnect from the Dashboard: clears the key and resets the core. + public Func OnDisconnect { get; set; } + + /// Start tracking from the Dashboard: persists tracking_enabled and (re)inits and starts. + public Func OnStart { get; set; } + + /// Stop tracking from the Dashboard: persists tracking_enabled=false and pauses. + public Func OnStop { get; set; } + + /// Raised on every state change; the tool window subscribes to refresh itself. + public event Action StateChanged; + + public void RaiseStateChanged(TrackerState state) => StateChanged?.Invoke(state); + + /// Current state from the client, or null if there is no client. + public TrackerState CurrentState => Client?.GetState(); + } +} diff --git a/visualstudio-extension/DevGlobe/EditorInfo.cs b/visualstudio-extension/DevGlobe/EditorInfo.cs new file mode 100644 index 0000000..4ec65a3 --- /dev/null +++ b/visualstudio-extension/DevGlobe/EditorInfo.cs @@ -0,0 +1,15 @@ +namespace DevGlobe +{ + /// + /// Editor identity sent to the core (the `editor` field of the `init` message) and used + /// as a tag in logs. + /// + public static class EditorInfo + { + /// Identifier recognized by devglobe.app (icon/name on the globe). + public const string EditorId = "visualstudio"; + + /// Always returns "visualstudio". + public static string DetectEditor() => EditorId; + } +} diff --git a/visualstudio-extension/DevGlobe/LanguageMap.cs b/visualstudio-extension/DevGlobe/LanguageMap.cs new file mode 100644 index 0000000..ac4eccf --- /dev/null +++ b/visualstudio-extension/DevGlobe/LanguageMap.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; + +namespace DevGlobe +{ + /// + /// Maps a language identifier to the canonical display name shown on the globe. + /// VS does not expose a languageId, so accepts a VS content-type + /// (e.g. "CSharp", "C/C++") OR a file extension (e.g. ".cs", "tsx") and returns the + /// canonical name (e.g. "C#", "React TSX"), falling back to capitalizing the first + /// letter for unknown identifiers. + /// + public static class LanguageMap + { + /// + /// Canonical table shared with the other DevGlobe extensions. + /// Key = language identifier, value = name displayed on the globe. Case-insensitive lookup. + /// + private static readonly Dictionary LANG_MAP = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "javascript", "JavaScript" }, { "typescript", "TypeScript" }, + { "javascriptreact", "React JSX" }, { "typescriptreact", "React TSX" }, + { "vue", "Vue" }, { "svelte", "Svelte" }, { "astro", "Astro" }, { "angular", "Angular" }, + { "html", "HTML" }, { "css", "CSS" }, { "sass", "Sass" }, { "scss", "SCSS" }, + { "less", "Less" }, { "stylus", "Stylus" }, + { "graphql", "GraphQL" }, { "mdx", "MDX" }, + { "handlebars", "Handlebars" }, { "pug", "Pug" }, { "jade", "Pug" }, { "ejs", "EJS" }, + { "erb", "ERB" }, { "haml", "Haml" }, { "twig", "Twig" }, { "blade", "Blade" }, + { "django-html", "Django" }, { "jinja", "Jinja" }, { "liquid", "Liquid" }, + { "mustache", "Mustache" }, { "razor", "Razor" }, { "nunjucks", "Nunjucks" }, + { "c", "C" }, { "cpp", "C++" }, { "rust", "Rust" }, { "go", "Go" }, { "zig", "Zig" }, + { "d", "D" }, { "v", "V" }, { "odin", "Odin" }, { "carbon", "Carbon" }, { "mojo", "Mojo" }, + { "java", "Java" }, { "kotlin", "Kotlin" }, { "scala", "Scala" }, { "groovy", "Groovy" }, + { "csharp", "C#" }, { "fsharp", "F#" }, { "vb", "Visual Basic" }, + { "python", "Python" }, { "ruby", "Ruby" }, { "php", "PHP" }, { "lua", "Lua" }, + { "perl", "Perl" }, { "r", "R" }, { "julia", "Julia" }, { "matlab", "MATLAB" }, + { "swift", "Swift" }, { "dart", "Dart" }, { "objective-c", "Objective-C" }, + { "objective-cpp", "Objective-C++" }, + { "haskell", "Haskell" }, { "elixir", "Elixir" }, { "erlang", "Erlang" }, + { "ocaml", "OCaml" }, { "elm", "Elm" }, { "purescript", "PureScript" }, + { "clojure", "Clojure" }, { "racket", "Racket" }, { "scheme", "Scheme" }, + { "commonlisp", "Common Lisp" }, { "prolog", "Prolog" }, + { "gleam", "Gleam" }, { "roc", "Roc" }, { "idris", "Idris" }, { "agda", "Agda" }, + { "lean", "Lean" }, { "coq", "Coq" }, + { "nim", "Nim" }, { "crystal", "Crystal" }, { "haxe", "Haxe" }, + { "ada", "Ada" }, { "fortran", "Fortran" }, { "pascal", "Pascal" }, { "cobol", "COBOL" }, + { "vhdl", "VHDL" }, { "verilog", "Verilog" }, { "systemverilog", "SystemVerilog" }, + { "asm", "Assembly" }, { "arm64", "ARM64" }, { "cuda", "CUDA" }, + { "glsl", "GLSL" }, { "hlsl", "HLSL" }, { "wgsl", "WGSL" }, { "metal", "Metal" }, + { "shaderlab", "ShaderLab" }, + { "shellscript", "Bash" }, { "powershell", "PowerShell" }, { "fish", "Fish" }, + { "bat", "Batch" }, + { "terraform", "Terraform" }, { "bicep", "Bicep" }, { "pulumi", "Pulumi" }, + { "nix", "Nix" }, { "ansible", "Ansible" }, { "puppet", "Puppet" }, + { "dockerfile", "Docker" }, { "docker-compose", "Docker Compose" }, + { "makefile", "Makefile" }, { "cmake", "CMake" }, { "just", "Just" }, { "meson", "Meson" }, + { "sql", "SQL" }, { "plsql", "PL/SQL" }, { "mysql", "MySQL" }, { "pgsql", "PostgreSQL" }, + { "mongodb", "MongoDB" }, { "redis", "Redis" }, { "cypher", "Cypher" }, + { "sparql", "SPARQL" }, { "prisma", "Prisma" }, + { "solidity", "Solidity" }, { "vyper", "Vyper" }, { "move", "Move" }, { "cairo", "Cairo" }, + { "gdscript", "GDScript" }, { "gdresource", "Godot Resource" }, + { "gdshader", "Godot Shader" }, + { "json", "JSON" }, { "jsonc", "JSON" }, { "jsonnet", "Jsonnet" }, + { "yaml", "YAML" }, { "toml", "TOML" }, { "xml", "XML" }, { "ini", "INI" }, + { "dotenv", "Config" }, { "properties", "Config" }, + { "csv", "CSV" }, { "tsv", "TSV" }, + { "cue", "CUE" }, { "dhall", "Dhall" }, { "pkl", "Pkl" }, + { "proto", "Protobuf" }, { "protobuf", "Protobuf" }, { "thrift", "Thrift" }, + { "avro", "Avro" }, + { "markdown", "Markdown" }, { "restructuredtext", "reStructuredText" }, + { "latex", "LaTeX" }, { "tex", "LaTeX" }, { "bibtex", "BibTeX" }, { "typst", "Typst" }, + { "asciidoc", "AsciiDoc" }, { "plaintext", "Plain Text" }, + { "coffeescript", "CoffeeScript" }, { "tcl", "Tcl" }, { "awk", "AWK" }, { "sed", "Sed" }, + { "regex", "Regex" }, { "diff", "Diff" }, { "git-commit", "Git Commit" }, + { "git-rebase", "Git Rebase" }, + { "ignore", "Gitignore" }, { "editorconfig", "EditorConfig" }, + { "http", "HTTP" }, { "ssh_config", "SSH Config" }, + { "log", "Log" }, + }; + + /// + /// Visual Studio content-type (IContentType.TypeName) -> LANG_MAP key. + /// Only covers content-types whose name differs from a canonical key. Any content-type + /// that already matches a LANG_MAP key (e.g. "JavaScript", "TypeScript", "F#") is + /// resolved by the direct lookup and needs no alias. + /// + private static readonly Dictionary CONTENT_TYPE_ALIASES = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "csharp", "csharp" }, + { "c/c++", "cpp" }, + { "basic", "vb" }, // "Basic" -> Visual Basic + { "htmlx", "html" }, // VS HTML editor + { "razor", "razor" }, + { "css", "css" }, + { "less", "less" }, + { "scss", "scss" }, + { "json", "json" }, + { "jade", "pug" }, + { "xaml", "xml" }, // XAML rendered as XML on the globe + { "code++.fortran", "fortran" }, + { "plain text", "plaintext" }, + }; + + /// + /// File extension (no leading dot, lowercase) -> LANG_MAP key. + /// Used when the VS content-type is absent or generic ("plaintext"). + /// + private static readonly Dictionary EXTENSION_ALIASES = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "cs", "csharp" }, { "csx", "csharp" }, + { "vb", "vb" }, + { "fs", "fsharp" }, { "fsx", "fsharp" }, { "fsi", "fsharp" }, + { "ts", "typescript" }, { "mts", "typescript" }, { "cts", "typescript" }, + { "tsx", "typescriptreact" }, + { "js", "javascript" }, { "mjs", "javascript" }, { "cjs", "javascript" }, + { "jsx", "javascriptreact" }, + { "vue", "vue" }, { "svelte", "svelte" }, { "astro", "astro" }, + { "razor", "razor" }, { "cshtml", "razor" }, { "vbhtml", "razor" }, + { "html", "html" }, { "htm", "html" }, + { "css", "css" }, { "scss", "scss" }, { "sass", "sass" }, { "less", "less" }, + { "c", "c" }, { "h", "c" }, + { "cpp", "cpp" }, { "cc", "cpp" }, { "cxx", "cpp" }, { "hpp", "cpp" }, + { "hh", "cpp" }, { "hxx", "cpp" }, { "ino", "cpp" }, + { "rs", "rust" }, { "go", "go" }, { "zig", "zig" }, + { "java", "java" }, { "kt", "kotlin" }, { "kts", "kotlin" }, + { "scala", "scala" }, { "groovy", "groovy" }, + { "py", "python" }, { "pyw", "python" }, { "pyi", "python" }, + { "rb", "ruby" }, { "php", "php" }, { "lua", "lua" }, { "pl", "perl" }, { "pm", "perl" }, + { "r", "r" }, { "jl", "julia" }, { "m", "matlab" }, + { "swift", "swift" }, { "dart", "dart" }, + { "hs", "haskell" }, { "ex", "elixir" }, { "exs", "elixir" }, + { "erl", "erlang" }, { "ml", "ocaml" }, { "elm", "elm" }, { "clj", "clojure" }, + { "nim", "nim" }, { "cr", "crystal" }, { "hx", "haxe" }, + { "json", "json" }, { "jsonc", "jsonc" }, + { "yaml", "yaml" }, { "yml", "yaml" }, { "toml", "toml" }, + { "xml", "xml" }, { "xaml", "xml" }, { "ini", "ini" }, + { "sql", "sql" }, { "graphql", "graphql" }, { "gql", "graphql" }, + { "sh", "shellscript" }, { "bash", "shellscript" }, { "zsh", "shellscript" }, + { "ps1", "powershell" }, { "psm1", "powershell" }, + { "bat", "bat" }, { "cmd", "bat" }, + { "tf", "terraform" }, { "bicep", "bicep" }, + { "dockerfile", "dockerfile" }, + { "md", "markdown" }, { "markdown", "markdown" }, { "mdx", "mdx" }, + { "tex", "latex" }, { "proto", "proto" }, + { "txt", "plaintext" }, { "log", "log" }, + }; + + /// + /// Maps a Visual Studio content-type or file extension to the canonical name. + /// Returns string.Empty for a null/empty input; for an unknown input, returns the + /// input with its first letter capitalized. + /// + public static string Map(string contentTypeOrExtension) + { + if (string.IsNullOrWhiteSpace(contentTypeOrExtension)) + { + return string.Empty; + } + + // Normalize: trim and strip a leading dot (extension ".cs" -> "cs"). + string raw = contentTypeOrExtension.Trim(); + string key = raw.StartsWith(".", StringComparison.Ordinal) ? raw.Substring(1) : raw; + + // VS content-type alias -> canonical key. + if (CONTENT_TYPE_ALIASES.TryGetValue(key, out string viaContentType) && + LANG_MAP.TryGetValue(viaContentType, out string nameFromContentType)) + { + return nameFromContentType; + } + + // File extension alias -> canonical key. + if (EXTENSION_ALIASES.TryGetValue(key, out string viaExtension) && + LANG_MAP.TryGetValue(viaExtension, out string nameFromExtension)) + { + return nameFromExtension; + } + + // Direct lookup (the input is already a canonical key, e.g. "javascript"). + if (LANG_MAP.TryGetValue(key, out string direct)) + { + return direct; + } + + // Fallback: capitalize the first letter, leave the rest unchanged. + return char.ToUpperInvariant(raw[0]) + raw.Substring(1); + } + } +} diff --git a/visualstudio-extension/DevGlobe/Logger.cs b/visualstudio-extension/DevGlobe/Logger.cs new file mode 100644 index 0000000..817afef --- /dev/null +++ b/visualstudio-extension/DevGlobe/Logger.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace DevGlobe +{ + /// + /// File logger for the extension. Writes to DevGlobeConfig.LogPath, one line per call, + /// in the format "ISO LEVEL [visualstudio] message {json-data}", with append and rotation. + /// + /// Levels: ERROR always; INFO/WARN only when debug is enabled (config.toml debug = true). + /// The logger must NEVER throw, otherwise it would break the IDE. + /// + public static class Log + { + private const long MaxLogBytes = 5 * 1024 * 1024; // rotate beyond 5 MB + private const int TruncateKeepBytes = 1 * 1024 * 1024; // keep the last 1 MB + + private static readonly object Gate = new object(); + private static bool _debug; + + /// + /// Re-evaluates the log level from config (debug = true enables INFO/WARN). + /// + public static void RefreshLevel() + { + try { _debug = DevGlobeConfig.IsDebugEnabled(); } + catch { _debug = false; } + } + + public static void Info(string msg, object? data = null) + { + if (_debug) Write("INFO", msg, data); + } + + public static void Warn(string msg, object? data = null) + { + if (_debug) Write("WARN", msg, data); + } + + public static void Error(string msg, object? data = null) + { + Write("ERROR", msg, data); + } + + private static void Write(string level, string msg, object? data) + { + try + { + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var payload = FormatData(data); + var line = $"{timestamp} {level} [{EditorInfo.EditorId}] {msg}{payload}\n"; + + var path = DevGlobeConfig.LogPath; + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + lock (Gate) + { + File.AppendAllText(path, line, new UTF8Encoding(false)); + MaybeRotate(path); + } + } + catch + { + // Logging must never break the host process. + } + } + + private static string FormatData(object? data) + { + if (data == null) return string.Empty; + try { return " " + JsonConvert.SerializeObject(data); } + catch { return " " + data; } + } + + private static void MaybeRotate(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists || info.Length <= MaxLogBytes) return; + + byte[] tail = new byte[TruncateKeepBytes]; + int read; + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) + { + fs.Seek(info.Length - TruncateKeepBytes, SeekOrigin.Begin); + read = fs.Read(tail, 0, TruncateKeepBytes); + } + + using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + fs.Write(tail, 0, read); + } + } + catch + { + // Rotation failure is non-fatal. + } + } + } +} diff --git a/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs b/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..fea9b0d --- /dev/null +++ b/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("DevGlobe")] +[assembly: AssemblyDescription("Show your live coding presence on the DevGlobe world map.")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("DevGlobe")] +[assembly: AssemblyProduct("DevGlobe")] +[assembly: AssemblyCopyright("Copyright © DevGlobe")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: AssemblyVersion("0.1.0.0")] +[assembly: AssemblyFileVersion("0.1.0.0")] diff --git a/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico b/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico new file mode 100644 index 0000000..0225949 Binary files /dev/null and b/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico differ diff --git a/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct b/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct new file mode 100644 index 0000000..ef7aef7 --- /dev/null +++ b/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + DevGlobe + DevGlobe + + + + + + DefaultDocked + + DevGlobe + DevGlobe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/visualstudio-extension/DevGlobe/StatusBar.cs b/visualstudio-extension/DevGlobe/StatusBar.cs new file mode 100644 index 0000000..d941274 --- /dev/null +++ b/visualstudio-extension/DevGlobe/StatusBar.cs @@ -0,0 +1,51 @@ +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace DevGlobe +{ + /// + /// Shows today's coding time in the VS status bar via IVsStatusbar. + /// + public sealed class DevGlobeStatusBar + { + private readonly IVsStatusbar _statusbar; + + public DevGlobeStatusBar(IVsStatusbar statusbar) + { + _statusbar = statusbar; + } + + /// Displays "⏱ 2h 15m". + public void UpdateTime(long todaySeconds) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + string label = "⏱ " + TimeFormat.Format(todaySeconds); + + // Unfreeze the text area in case another component froze it. + int frozen; + _statusbar.IsFrozen(out frozen); + if (frozen != 0) + { + _statusbar.FreezeOutput(0); + } + + _statusbar.SetText(label); + } + + /// Clears the status bar text. + public void Hide() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + int frozen; + _statusbar.IsFrozen(out frozen); + if (frozen != 0) + { + _statusbar.FreezeOutput(0); + } + + _statusbar.Clear(); + } + } +} diff --git a/visualstudio-extension/DevGlobe/TimeFormat.cs b/visualstudio-extension/DevGlobe/TimeFormat.cs new file mode 100644 index 0000000..24cc9fc --- /dev/null +++ b/visualstudio-extension/DevGlobe/TimeFormat.cs @@ -0,0 +1,23 @@ +namespace DevGlobe +{ + /// + /// Formats a duration in seconds as a short label ("2h 15m" / "15m"). + /// + public static class TimeFormat + { + public static string Format(long todaySeconds) + { + if (todaySeconds < 0) + { + todaySeconds = 0; + } + + long hours = todaySeconds / 3600; + long minutes = (todaySeconds % 3600) / 60; + + return hours > 0 + ? $"{hours}h {minutes}m" + : $"{minutes}m"; + } + } +} diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml new file mode 100644 index 0000000..5c4584e --- /dev/null +++ b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +