Skip to content
This repository has been archived by the owner on May 7, 2024. It is now read-only.

Commit

Permalink
Support connecting Grammarly account on github.dev (#246)
Browse files Browse the repository at this point in the history
* Use IndexDB to persist data in localStorage

* restart on error

* Use vscode.env.asExternalUri() to get a redirect URI in web extension

* add changeset

* use a netlify function to handle redirect using state

Co-authored-by: Rahul Kadyan <rahul.kadyan@grammarly.com>
  • Loading branch information
znck and znck committed May 11, 2022
1 parent bdbee32 commit 2de7e79
Show file tree
Hide file tree
Showing 12 changed files with 900 additions and 28 deletions.
6 changes: 6 additions & 0 deletions .changeset/happy-coats-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'grammarly': minor
'grammarly-languageserver': patch
---

Support for connected Grammarly account in web extension (https://github.dev and https://vscode.dev)
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ node_modules/
*.vsix
dist/
.tmp/
.vscode-test-web/

# Local Netlify folder
.netlify
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
"editor.formatOnSave": true,
"cSpell.words": [
"AUTOCORRECT",
"Emogenie",
"Grammarly",
"capi",
"docid",
"Emogenie",
"errored",
"freews",
"gnar",
"Grammarly",
"heatmap",
"inversify",
"languageclient",
"minicard",
"reqid",
"subalerts",
"textdocument"
],
Expand Down
38 changes: 23 additions & 15 deletions extension/src/GrammarlyClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@ import {
import { Registerable } from './interfaces'

export class GrammarlyClient implements Registerable {
public client: GrammarlyLanguageClient
public client!: GrammarlyLanguageClient
private session?: Disposable
private callbacks = new Set<() => unknown>()
private isReady = false
private selectors: DocumentFilter[] = []

constructor(private readonly context: ExtensionContext) {
this.client = this.createClient()
}
constructor(private readonly context: ExtensionContext) {}

public onReady(fn: () => unknown): Disposable {
this.callbacks.add(fn)
Expand Down Expand Up @@ -64,11 +62,15 @@ export class GrammarlyClient implements Registerable {
{
id: 'client_BaDkMgx4X19X9UxxYRCXZo',
name: 'Grammarly',
outputChannel: window.createOutputChannel('Grammarly'),
documentSelector: this.selectors
.map((selector) =>
selector.language != null || selector.pattern != null || selector.scheme != null ? (selector as any) : null,
)
.filter(<T>(value: T | null): value is T => value != null),

revealOutputChannelOn: 3,
progressOnInitialization: true,
errorHandler: {
error(error) {
window.showErrorMessage(error.message)
Expand All @@ -90,9 +92,7 @@ export class GrammarlyClient implements Registerable {
handleUri: async (uri) => {
if (uri.path === '/auth/callback') {
try {
await this.client.protocol.handleOAuthCallbackUri(
`${uri.scheme}://${uri.authority}${uri.path}?${decodeURIComponent(uri.query)}`,
)
await this.client.protocol.handleOAuthCallbackUri(uri.toString(true))
} catch (error) {
await window.showErrorMessage((error as Error).message)
return
Expand Down Expand Up @@ -148,16 +148,17 @@ export class GrammarlyClient implements Registerable {
await this.client.protocol.dismissSuggestion(options)
}),
commands.registerCommand('grammarly.login', async () => {
if (env.appHost !== 'desktop') {
await window.showErrorMessage('Connected account is not supported in web extension yet.')
return
}
const internalRedirectUri = Uri.parse(`${env.uriScheme}://znck.grammarly/auth/callback`, true)
const externalRedirectUri = await env.asExternalUri(internalRedirectUri)

const url = await this.client.protocol.getOAuthUrl(
Uri.from({ scheme: env.uriScheme, authority: 'znck.grammarly', path: '/auth/callback' }).toString(),
)
const isExternalURLDifferent = internalRedirectUri.toString(true) === externalRedirectUri.toString(true)
const redirectUri = isExternalURLDifferent
? internalRedirectUri.toString(true)
: 'https://vscode-extension-grammarly.netlify.app/.netlify/functions/redirect'
const url = new URL(await this.client.protocol.getOAuthUrl(redirectUri))
url.searchParams.set('state', toBase64URL(externalRedirectUri.toString(true)))

if (!(await env.openExternal(Uri.parse(url)))) {
if (!(await env.openExternal(Uri.parse(url.toString(), true)))) {
await window.showErrorMessage('Failed to open login page.')
}
}),
Expand Down Expand Up @@ -186,6 +187,8 @@ export class GrammarlyClient implements Registerable {
console.error(error)
}
})
} catch (error) {
await window.showErrorMessage(`The extension couldn't be started. See the output channel for details.`)
} finally {
statusbar.dispose()
}
Expand All @@ -195,3 +198,8 @@ export class GrammarlyClient implements Registerable {
function isNode(): boolean {
return typeof process !== 'undefined' && process.versions?.node != null
}

function toBase64URL(text: string): string {
if (typeof Buffer !== 'undefined') return Buffer.from(text, 'utf-8').toString('base64url')
return btoa(text).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
20 changes: 16 additions & 4 deletions extension/src/StatusBarController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,20 @@ export class StatusBarController {

public register() {
this.update()

let isRestarting = false
return Disposable.from(
this.#statusbar,
workspace.onDidCloseTextDocument(() => this.update()),
window.onDidChangeActiveTextEditor(() => this.update()),
commands.registerCommand('grammarly.restartServer', async () => {
// TODO: Restart?
if (isRestarting) return
try {
isRestarting = true
await this.grammarly.start()
} finally {
isRestarting = false
}
}),
)
}
Expand Down Expand Up @@ -62,9 +70,13 @@ export class StatusBarController {
role: status === 'error' ? 'button' : undefined,
}

this.#statusbar.tooltip = `Your Grammarly account is ${
isUser ? '' : 'not '
}used for this file. \nConnection status: ${status}`
this.#statusbar.tooltip = [
`Your Grammarly account is ${isUser ? '' : 'not '}used for this file.`,
`Connection status: ${status}`,
status === 'error' ? `Restart now?` : null,
]
.filter(Boolean)
.join('\n')
this.#statusbar.command = status === 'error' ? 'grammarly.restartServer' : undefined
this.#statusbar.show()
}
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@
"build:wasm": "node scripts/build-wasm.mjs",
"watch": "rollup --watch -c",
"test": "jest",
"release": "changeset version"
"release": "changeset version",
"open-in-browser": "vscode-test-web --host 127.0.0.1 --browser firefox --extensionDevelopmentPath=./extension ./fixtures"
},
"devDependencies": {
"@changesets/cli": "^2.22.0",
"@netlify/functions": "^1.0.0",
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.2.1",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.2",
"@vscode/test-web": "^0.0.24",
"@vuedx/monorepo-tools": "^0.2.2-next-1651055813.0",
"esbuild": "^0.14.38",
"husky": "^7.0.4",
Expand Down
1 change: 1 addition & 0 deletions packages/grammarly-languageserver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"dependencies": {
"@grammarly/sdk": "^1.7.4",
"htmlparser2": "^8.0.1",
"idb-keyval": "^6.1.0",
"inversify": "^6.0.1",
"reflect-metadata": "^0.1.13",
"vscode-languageserver": "^7.0.0",
Expand Down
42 changes: 42 additions & 0 deletions packages/grammarly-languageserver/src/VirtualStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createStore, get, set, keys, del, delMany } from 'idb-keyval'

export class VirtualStorage {
public items = new Map<string, string>()

Expand Down Expand Up @@ -25,3 +27,43 @@ export class VirtualStorage {
this.items.set(key, value)
}
}

export class IDBStorage extends VirtualStorage {
private store = createStore('grammarly-languageserver', 'localStorage')

async load(): Promise<void> {
for (const key of await keys<string>(this.store)) {
const value = await get<string>(key, this.store)
if (value != null) {
this.items.set(key, value)
}
}
}

clear(): void {
delMany(Array.from(this.items.keys()), this.store)
super.clear()
}

getItem(key: string): string | null {
get(key, this.store).then((value) => {
if (value != null) {
this.items.set(key, value)
} else {
this.items.delete(key)
}
})

return super.getItem(key)
}

removeItem(key: string): void {
del(key, this.store)
this.items.delete(key)
}

setItem(key: string, value: string): void {
set(key, value, this.store)
this.items.set(key, value)
}
}
4 changes: 2 additions & 2 deletions packages/grammarly-languageserver/src/createLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Options {
getConnection(): ReturnType<typeof createConnection>
createTextDocuments<T>(config: TextDocumentsConfiguration<T>): TextDocuments<T>
init(clientId: string): Promise<SDK>
pathEnvironmentForSDK(clientId: string): void
pathEnvironmentForSDK(clientId: string): void | Promise<void>
}

export function createLanguageServer({
Expand Down Expand Up @@ -58,7 +58,7 @@ export function createLanguageServer({
connection.onInitialize(async (params) => {
const options = params.initializationOptions as { clientId: string } | undefined
if (options?.clientId == null) throw new Error('clientId is required')
pathEnvironmentForSDK(options.clientId)
await pathEnvironmentForSDK(options.clientId)
const sdk = await init(options.clientId)

container.bind(CLIENT).toConstantValue(params.capabilities)
Expand Down
14 changes: 10 additions & 4 deletions packages/grammarly-languageserver/src/index.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from 'vscode-languageserver/browser'
import { createLanguageServer } from './createLanguageServer'
import { DOMParser } from './DOMParser'
import { VirtualStorage } from './VirtualStorage'
import { IDBStorage, VirtualStorage } from './VirtualStorage'
function getConnection() {
const messageReader = new BrowserMessageReader(self as unknown as Worker)
const messageWriter = new BrowserMessageWriter(self as unknown as Worker)
Expand All @@ -21,8 +21,10 @@ function createTextDocuments<T>(config: TextDocumentsConfiguration<T>): TextDocu

// Polyfill DOMParser as it is not available in worker.
if (!('DOMParser' in globalThis)) (globalThis as any).DOMParser = DOMParser
if (!('localStorage' in globalThis)) (globalThis as any).localStorage = new VirtualStorage()
if (!('sessionStorage' in globalThis)) (globalThis as any).sessionStorage = new VirtualStorage()
const localStorage = new IDBStorage()
if (!('localStorage' in globalThis)) (globalThis as any).localStorage = localStorage
const sessionStorage = new VirtualStorage()
if (!('sessionStorage' in globalThis)) (globalThis as any).sessionStorage = sessionStorage

export const startLanguageServer = createLanguageServer({
getConnection,
Expand All @@ -31,5 +33,9 @@ export const startLanguageServer = createLanguageServer({
// @ts-ignore
return new globalThis.Grammarly.SDK(clientId)
},
pathEnvironmentForSDK() {},
async pathEnvironmentForSDK() {
if (globalThis.localStorage === localStorage) {
await localStorage.load()
}
},
})
Loading

0 comments on commit 2de7e79

Please sign in to comment.