Skip to content

Commit 970e191

Browse files
Merge pull request #635 from highperformancecoder/copilot/create-angular-login-component
Add Clerk authentication with Angular login component and Electron IPC token storage
2 parents 12565ba + ecf938f commit 970e191

File tree

23 files changed

+21320
-18812
lines changed

23 files changed

+21320
-18812
lines changed

gui-js/apps/minsky-electron/src/app/events/electron.events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,8 @@ ipcMain.handle(events.OPEN_URL, (event,options)=> {
288288
let window=WindowManager.createWindow(options);
289289
window.loadURL(options.url);
290290
});
291+
292+
ipcMain.handle(events.SET_AUTH_TOKEN, async (event, token: string | null) => {
293+
CommandsManager.stashClerkToken(token);
294+
return { success: true };
295+
});

gui-js/apps/minsky-electron/src/app/managers/ApplicationMenuManager.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ export class ApplicationMenuManager {
127127
window.loadURL('https://www.patreon.com/logout');
128128
},
129129
},
130+
{
131+
label: 'Upgrade via Clerk',
132+
click() {CommandsManager.upgradeUsingClerk();},
133+
},
134+
{
135+
label: 'Manage Clerk Session',
136+
click() {WindowManager.openLoginWindow();},
137+
},
138+
130139
{
131140
label: 'New System',
132141
accelerator: 'CmdOrCtrl + Shift + N',

gui-js/apps/minsky-electron/src/app/managers/CommandsManager.ts

Lines changed: 177 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,67 @@ import ProgressBar from 'electron-progressbar';
2626
import {exec,spawn} from 'child_process';
2727
import decompress from 'decompress';
2828
import {promisify} from 'util';
29+
import {net, safeStorage } from 'electron';
30+
31+
function semVer(version: string) {
32+
const pattern=/(\d+)\.(\d+)\.(\d+)/;
33+
let [,major,minor,patch]=pattern.exec(version);
34+
return {major: +major,minor: +minor, patch: +patch};
35+
}
36+
function semVerLess(x: string, y: string): boolean {
37+
let xver=semVer(x), yver=semVer(y);
38+
return xver.major<yver.major ||
39+
xver.major===yver.major && (
40+
xver.minor<yver.minor ||
41+
xver.minor===yver.minor && xver.patch<yver.patch
42+
);
43+
}
44+
45+
const backendAPI='https://minskybe-x7dj1.sevalla.app/api';
46+
// perform a call on the backend API, returning the JSON encoded result
47+
// options is passed to the constructor of a ClienRequest object https://www.electronjs.org/docs/latest/api/client-request#requestendchunk-encoding-callback
48+
async function callBackendAPI(options: string|Object, token: string) {
49+
return new Promise<string>((resolve, reject)=> {
50+
let request=net.request(options);
51+
request.setHeader('Authorization',`Bearer ${token}`);
52+
request.on('response', (response)=>{
53+
let chunks=[];
54+
response.on('data', (chunk)=>{chunks.push(chunk);});
55+
response.on('end', ()=>resolve(Buffer.concat(chunks).toString()));
56+
response.on('error',()=>reject(response.statusMessage));
57+
});
58+
request.on('error',(err)=>reject(err.toString()));
59+
request.end();
60+
});
61+
}
62+
63+
// to handle redirects
64+
async function getFinalUrl(initialUrl, token) {
65+
try {
66+
const response = await fetch(initialUrl, {
67+
method: 'GET',
68+
headers: {
69+
'Authorization': `Bearer ${token}`
70+
},
71+
redirect: 'manual' // This tells fetch NOT to follow the link automatically
72+
});
73+
74+
// In 'manual' mode, a redirect returns an 'opaqueredirect' type or status 302
75+
if (response.status >= 300 && response.status < 400) {
76+
const redirectUrl = response.headers.get('location');
77+
if (redirectUrl) return redirectUrl;
78+
}
79+
80+
if (response.ok) return initialUrl;
81+
82+
throw new Error(`Server responded with ${response.status}`);
83+
} catch (error) {
84+
// If redirect: 'manual' is used, fetch might throw a 'TypeError'
85+
// when it hits the redirect—this is actually what we want to catch.
86+
console.error("Fetch encountered the redirect/error:", error);
87+
throw error;
88+
}
89+
}
2990

3091
export class CommandsManager {
3192
static activeGodleyWindowItems = new Map<string, CanvasItem>();
@@ -1137,6 +1198,7 @@ export class CommandsManager {
11371198

11381199
// handler for downloading Ravel and installing it
11391200
static downloadRavel(event,item,webContents) {
1201+
11401202
switch (process.platform) {
11411203
case 'win32':
11421204
const savePath=dirname(process.execPath)+'/libravel.dll';
@@ -1158,7 +1220,7 @@ export class CommandsManager {
11581220
// handler for when download completed
11591221
item.once('done', (event,state)=>{
11601222
progress.close();
1161-
1223+
11621224
if (state==='completed') {
11631225
dialog.showMessageBoxSync(WindowManager.getMainWindow(),{
11641226
message: 'Ravel plugin updated successfully - restart Ravel to use',
@@ -1308,6 +1370,44 @@ export class CommandsManager {
13081370
modal: false,
13091371
});
13101372
}
1373+
1374+
// return information about the current system
1375+
static async buildState(previous: boolean) {
1376+
// need to pass what platform we are
1377+
let state;
1378+
switch (process.platform) {
1379+
case 'win32':
1380+
state={system: 'windows', distro: '', version: '', arch:'', previous: ''};
1381+
break;
1382+
case 'darwin':
1383+
state={system: 'macos', distro: '', version: '', arch: `${process.arch}`, previous: ''};
1384+
break;
1385+
case 'linux': {
1386+
state={system: 'linux', distro: '', version: '',arch:'', previous: ''};
1387+
// figure out distro and version from /etc/os-release
1388+
let aexec=promisify(exec);
1389+
let osRelease='/etc/os-release';
1390+
if (existsSync(process.resourcesPath+'/os-release'))
1391+
osRelease=process.resourcesPath+'/os-release';
1392+
let distroInfo=await aexec(`grep ^ID= ${osRelease}`);
1393+
// value may or may not be quoted
1394+
let extractor=/.*=['"]?([^'"\n]*)['"]?/;
1395+
state.distro=extractor.exec(distroInfo.stdout)[1];
1396+
distroInfo=await aexec(`grep ^VERSION_ID= ${osRelease}`);
1397+
state.version=extractor.exec(distroInfo.stdout)[1];
1398+
break;
1399+
}
1400+
default:
1401+
dialog.showMessageBoxSync(WindowManager.getMainWindow(),{
1402+
message: `In app update is not available for your operating system yet, please check back later`,
1403+
type: 'error',
1404+
});
1405+
return null;
1406+
}
1407+
if (await minsky.ravelAvailable() && previous)
1408+
state.previous=/[^:]*/.exec(await minsky.ravelVersion())[0];
1409+
return state;
1410+
}
13111411

13121412
static async upgrade(installCase: InstallCase=InstallCase.theLot) {
13131413
const window=this.createDownloadWindow();
@@ -1344,7 +1444,7 @@ export class CommandsManager {
13441444
}
13451445
if (ravelFile) {
13461446
// currently on latest, so reinstall ravel
1347-
window.webContents.session.on('will-download',this.downloadRavel);
1447+
window.webContents.session.on('will-download',this.downloadRavel);
13481448
window.webContents.downloadURL(ravelFile);
13491449
return;
13501450
}
@@ -1357,44 +1457,87 @@ export class CommandsManager {
13571457
}
13581458
});
13591459

1360-
let clientId='-PiL7snNmZL_BlLJTPm62SHBcFTMG5d46m2336r118mfrp6sz4ty0g-thbKAs76c';
1361-
// need to pass what platform we are
1362-
let state;
1363-
switch (process.platform) {
1364-
case 'win32':
1365-
state={system: 'windows', distro: '', version: '', arch:'', previous: ''};
1366-
break;
1367-
case 'darwin':
1368-
state={system: 'macos', distro: '', version: '', arch: `${process.arch}`, previous: ''};
1369-
break;
1370-
case 'linux':
1371-
state={system: 'linux', distro: '', version: '',arch:'', previous: ''};
1372-
// figure out distro and version from /etc/os-release
1373-
let aexec=promisify(exec);
1374-
let osRelease='/etc/os-release';
1375-
if (existsSync(process.resourcesPath+'/os-release'))
1376-
osRelease=process.resourcesPath+'/os-release';
1377-
let distroInfo=await aexec(`grep ^ID= ${osRelease}`);
1378-
// value may or may not be quoted
1379-
let extractor=/.*=['"]?([^'"\n]*)['"]?/;
1380-
state.distro=extractor.exec(distroInfo.stdout)[1];
1381-
distroInfo=await aexec(`grep ^VERSION_ID= ${osRelease}`);
1382-
state.version=extractor.exec(distroInfo.stdout)[1];
1383-
break;
1384-
default:
1385-
dialog.showMessageBoxSync(WindowManager.getMainWindow(),{
1386-
message: `In app update is not available for your operating system yet, please check back later`,
1387-
type: 'error',
1388-
});
1460+
let state=await CommandsManager.buildState(installCase==InstallCase.previousRavel);
1461+
if (!state) {
13891462
window.close();
13901463
return;
1391-
break;
13921464
}
1393-
if (await minsky.ravelAvailable() && installCase===InstallCase.previousRavel)
1394-
state.previous=/[^:]*/.exec(await minsky.ravelVersion())[0];
1465+
let clientId='-PiL7snNmZL_BlLJTPm62SHBcFTMG5d46m2336r118mfrp6sz4ty0g-thbKAs76c';
13951466
let encodedState=encodeURI(JSON.stringify(state));
13961467
// load patreon's login page
13971468
window.loadURL(`https://www.patreon.com/oauth2/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://ravelation.net/ravel-downloader.cgi&scope=identity%20identity%5Bemail%5D&state=${encodedState}`);
13981469
}
1470+
1471+
1472+
// gets release URL for current system from Ravelation.net backend
1473+
static async getRelease(product: string, previous: boolean, token: string) {
1474+
let state=await CommandsManager.buildState(previous);
1475+
if (!state) return '';
1476+
let query=`product=${product}&os=${state.system}&arch=${state.arch}&distro=${state.distro}&distro_version=${state.version}`;
1477+
if (previous) {
1478+
let releases=JSON.parse(await callBackendAPI(`${backendAPI}/releases?${query}`, token));
1479+
let prevRelease;
1480+
for (let release of releases)
1481+
if (semVerLess(release.version, state.previous))
1482+
prevRelease=release;
1483+
if (prevRelease) return prevRelease.download_url;
1484+
// if not, then treat the request as latest
1485+
}
1486+
let release=JSON.parse(await callBackendAPI(`${backendAPI}/releases/latest?${query}`, token));
1487+
return release?.release?.download_url;
1488+
}
1489+
1490+
static stashClerkToken(token: string) {
1491+
if (token) {
1492+
if (safeStorage.isEncryptionAvailable()) {
1493+
const encrypted = safeStorage.encryptString(token);
1494+
StoreManager.store.set('authToken', encrypted.toString('latin1'));
1495+
} else
1496+
// fallback: store plaintext
1497+
StoreManager.store.set('authToken', token);
1498+
} else {
1499+
StoreManager.store.delete('authToken');
1500+
}
1501+
}
13991502

1503+
static async upgradeUsingClerk(installCase: InstallCase=InstallCase.theLot) {
1504+
while (!StoreManager.store.get('authToken'))
1505+
if (!await WindowManager.openLoginWindow()) return;
1506+
1507+
let token=StoreManager.store.get('authToken');
1508+
// decrypt token if encrypted
1509+
if (safeStorage.isEncryptionAvailable())
1510+
token=safeStorage.decryptString(Buffer.from(token, 'latin1'));
1511+
1512+
const window=WindowManager.getMainWindow();
1513+
let minskyAsset;
1514+
try {
1515+
if (installCase===InstallCase.theLot)
1516+
minskyAsset=await CommandsManager.getRelease('minsky', false, token);
1517+
let ravelAsset=await CommandsManager.getRelease('ravel', installCase===InstallCase.previousRavel, token);
1518+
1519+
if (minskyAsset) {
1520+
if (ravelAsset) { // stash ravel upgrade to be installed on next startup
1521+
StoreManager.store.set('ravelPlugin',await getFinalUrl(ravelAsset,token));
1522+
}
1523+
window.webContents.session.on('will-download',this.downloadMinsky);
1524+
window.webContents.downloadURL(await getFinalUrl(minskyAsset,token));
1525+
return;
1526+
} else if (ravelAsset) {
1527+
window.webContents.session.on('will-download',this.downloadRavel);
1528+
window.webContents.downloadURL(await getFinalUrl(ravelAsset,token));
1529+
return;
1530+
}
1531+
dialog.showMessageBoxSync(WindowManager.getMainWindow(),{
1532+
message: "Everything's up to date, nothing to do.\n"+
1533+
"If you're trying to download the Ravel plugin, please ensure you are logged into an account subscribed to Ravel Fan or Explorer tiers.",
1534+
type: 'info',
1535+
});
1536+
}
1537+
catch (error) {
1538+
dialog.showErrorBox('Error', error.toString());
1539+
}
1540+
1541+
}
1542+
14001543
}

gui-js/apps/minsky-electron/src/app/managers/StoreManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface MinskyStore {
1919
defaultModelDirectory: string;
2020
defaultDataDirectory: string;
2121
ravelPlugin: string; // used for post installation installation of Ravel
22+
authToken?: string;
2223
}
2324

2425
class StoreManager {

gui-js/apps/minsky-electron/src/app/managers/WindowManager.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export class WindowManager {
2828
static canvasWidth: number;
2929
static scaleFactor: number;
3030
static currentTab=minsky.canvas as RenderNativeWindow;
31+
// Pending resolver for the auth-token promise created by openLoginWindow()
32+
static _resolveAuthToken: ((token: string | null) => void) | null = null;
3133

3234
static activeWindows = new Map<number, ActiveWindow>();
3335
private static uidToWindowMap = new Map<string, ActiveWindow>();
@@ -381,4 +383,23 @@ export class WindowManager {
381383
catch (err) {} // absorb any exceptions due to windows disappearing
382384
}
383385
}
386+
387+
static async openLoginWindow() {
388+
const existingToken = StoreManager.store.get('authToken') || '';
389+
const loginWindow = WindowManager.createPopupWindowWithRouting({
390+
width: 420,
391+
height: 500,
392+
title: 'Login',
393+
modal: false,
394+
url: `#/headless/login?authToken=${encodeURIComponent(existingToken)}`,
395+
});
396+
397+
return new Promise<string>((resolve)=>{
398+
// Resolve with null if the user closes the window before authenticating
399+
loginWindow.once('closed', () => {
400+
resolve(StoreManager.store.get('authToken'));
401+
});
402+
});
403+
}
404+
384405
}

gui-js/apps/minsky-web/src/app/app-routing.module.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
EditHandleDescriptionComponent,
2929
EditHandleDimensionComponent,
3030
PickSlicesComponent,
31-
LockHandlesComponent
31+
LockHandlesComponent,
32+
LoginComponent,
3233
} from '@minsky/ui-components';
3334

3435
const routes: Routes = [
@@ -149,6 +150,10 @@ const routes: Routes = [
149150
path: 'headless/variable-pane',
150151
component: VariablePaneComponent,
151152
},
153+
{
154+
path: 'headless/login',
155+
component: LoginComponent,
156+
},
152157
{
153158
path: '**',
154159
component: PageNotFoundComponent,

gui-js/apps/minsky-web/src/app/app.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
}
66

77
@if (!loading) {
8-
@if (router.url.includes('headless');) {
8+
@if (router.url.includes('headless')) {
99
<router-outlet></router-outlet>
1010
}
1111
@else {

gui-js/apps/minsky-web/src/environments/environment.dev.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
export const AppConfig = {
77
production: false,
88
environment: 'DEV',
9+
clerkPublishableKey: 'pk_test_cG9zaXRpdmUtcGhvZW5peC04NS5jbGVyay5hY2NvdW50cy5kZXYk',
910
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const AppConfig = {
22
production: true,
33
environment: 'PROD',
4+
clerkPublishableKey: 'pk_test_cG9zaXRpdmUtcGhvZW5peC04NS5jbGVyay5hY2NvdW50cy5kZXYk',
45
};

0 commit comments

Comments
 (0)