From f01773f93246d02c0385eb36e686ca183bac3f8c Mon Sep 17 00:00:00 2001 From: heapwolf Date: Sat, 16 Mar 2024 20:39:32 +0100 Subject: [PATCH] wip sharing --- socket.ini | 2 +- src/components/editor.js | 34 ++++- src/components/properties.js | 249 ++++++++++++++++++++++------------- src/components/publish.js | 203 ++++++++++++++++++---------- src/components/subscribe.js | 2 +- src/css/index.css | 19 ++- src/css/properties.css | 31 +++++ src/css/publish.css | 27 ++-- src/css/theme.css | 2 +- src/css/tonic-overrides.css | 52 +++++--- src/index.html | 1 + src/index.js | 77 +++++------ 12 files changed, 451 insertions(+), 248 deletions(-) create mode 100644 src/css/properties.css diff --git a/socket.ini b/socket.ini index ae17414..90d4171 100644 --- a/socket.ini +++ b/socket.ini @@ -29,7 +29,7 @@ ; copy_map = src/mapping.ini ; An list of environment variables, separated by commas. -env = HOME, USER, TMPDIR, PWD +env = HOME, USER, TMPDIR, PWD, DEBUG ; Advanced Compiler Settings (ie C++ compiler -02, -03, etc). flags = -O3 diff --git a/src/components/editor.js b/src/components/editor.js index 5da4c1a..61213e9 100644 --- a/src/components/editor.js +++ b/src/components/editor.js @@ -7,6 +7,15 @@ import Tonic from '@socketsupply/tonic' import { resizePNG } from '../lib/icon.js' +function escapeCommitMessage (commitMessage) { + return commitMessage + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function rgbaToHex (rgbaString) { const rgbaValues = rgbaString.match(/\d+/g) @@ -139,6 +148,22 @@ class AppEditor extends Tonic { const fileName = projectNode.label const imagePreview = this.querySelector('.image-preview') + if (projectNode.isDirectory && projectNode.parent.id === 'root') { + // in this case we can read all the patch files in the database + // and present them to the user in the form of "patch requests". + + /* + * Each patch file has a header like this, so we can parse it, present + * it, and the user can decide if they want to apply the patch or not. + * + * From 97d72567aeb446e2b58262e88623f77ad0f7044f Mon Sep 17 00:00:00 2001 + * From: heapwolf + * Date: Thu, 14 Mar 2024 10:48:03 +0100 + * Subject: [PATCH] fix write method and preview script + * + */ + } + if (projectNode.isDirectory && fileName === 'icons') { const iconPath = path.join(projectNode.id, 'icon.png') const data = await fs.promises.readFile(iconPath) @@ -276,7 +301,6 @@ class AppEditor extends Tonic { if (!this.projectNode) return const value = this.editor.getValue() const coTerminal = document.querySelector('app-terminal') - const coProperties = document.querySelector('app-properties') if (this.projectNode.label === 'settings.json' && this.projectNode.parent.id === 'root') { @@ -286,11 +310,17 @@ class AppEditor extends Tonic { coTerminal.error(`Unable to parse settings file (${err.message})`) return } + coTerminal.info(`Settings file updated.`) - coProperties.reRender() parent.activatePreviewWindows() } + clearTimeout(this.debouncePropertiesRerender) + this.debouncePropertiesRerender = setTimeout(() => { + const coProperties = document.querySelector('app-properties') + coProperties.reRender() + }, 1024) + this.writeToDisk(this.projectNode, value) }) diff --git a/src/components/properties.js b/src/components/properties.js index b3192eb..044d3fb 100644 --- a/src/components/properties.js +++ b/src/components/properties.js @@ -1,29 +1,11 @@ import Tonic from '@socketsupply/tonic' import fs from 'socket:fs' import path from 'socket:path' +import { exec } from 'socket:child_process' +import { Encryption, sha256 } from 'socket:network' import * as ini from '../lib/ini.js' -function trim (string) { - const lines = string.split(/\r?\n/) - - let leadingSpaces = 0 - - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim() !== '') { - leadingSpaces = lines[i].search(/\S/) - break - } - } - - for (let i = 0; i < lines.length; i++) { - lines[i] = lines[i].slice(leadingSpaces).trimRight() - } - - if (lines[0] === '') lines.shift() - return lines.join('\n') -} - class AppProperties extends Tonic { constructor () { super() @@ -100,27 +82,39 @@ class AppProperties extends Tonic { const { event, propertyValue } = el.dataset - if (event === 'ext') { - // TODO + if (event === 'publish') { + const coDialogPublish = document.querySelector('dialog-publish') + if (coDialogPublish) coDialogPublish.show() } } async loadProjectNode (node) { - try { - const pathToConfig = path.join(node.id, 'socket.ini') - this.state.data = await fs.promises.readFile(pathToConfig, 'utf8') - } catch { - return false - } - this.reRender() return true } async render () { - let data = this.state.data || '' + let src = '' + + const app = this.props.parent + const settings = app.state.settings + const currentProject = app.state.currentProject + const cwd = currentProject?.id + + if (currentProject) { + try { + const pathToConfigFile = path.join(cwd, 'socket.ini') + src = await fs.promises.readFile(pathToConfigFile, 'utf8') + } catch (err) { + const notifications = document.querySelector('#notifications') + notifications?.create({ + type: 'error', + title: 'Error', + message: err.message + }) + } + } - const settings = this.props.parent.state.settings const previewWindows = [] if (settings?.previewWindows) { @@ -145,8 +139,105 @@ class AppProperties extends Tonic { } } + let bundleId = ini.get(src, 'meta', 'bundle_identifier') + if (bundleId) bundleId = bundleId.replace(/"/g, '') + + let sharedSecret = '' + + const { data: hasBundle } = await app.db.projects.has(bundleId) + + if (hasBundle) { + const { data: dataBundle } = await app.db.projects.get(bundleId) + sharedSecret = dataBundle.sharedSecret + } else if (cwd) { + // + // The clusterId is hard coded for now. + // + const cluster = await sha256('socket-app-studio', { bytes: true }) + const clusterId = cluster.toString('base64') + + sharedSecret = (await Encryption.createId()).toString('hex') + const sharedKey = await Encryption.createSharedKey(sharedSecret) + const derivedKeys = await Encryption.createKeyPair(sharedKey) + + const subcluster = Buffer.from(derivedKeys.publicKey) + const subclusterId = subcluster.toString('base64') + + // + // Projects are keyed off the bundleId + // + await app.db.projects.put(bundleId, { + bundleId, + clusterId, + subclusterId, + sharedKey, + sharedSecret // TODO(@heapwolf): encrypt sharedSecret via initial global password + }) + + // + // We need to tell the network to start listening for this subcluster + // + await app.initNetwork() + } + + let projectUpdates = [] + let gitStatus = { stdout: '' } + + if (cwd) { + // + // If there is a current project, check if its been git initialized. + // + try { + await fs.promises.stat(path.join(cwd, '.git')) + } catch (err) { + try { + gitStatus = await exec('git init', { cwd }) + } catch (err) { + gitStatus.stderr = err.message + } + + if (gitStatus?.stderr.includes('command not found')) { + projectUpdates.push(this.html` + Git is not installed and is required to use this program. + + `) + } + } + + // + // Try to get the status of the project to tell the user what + // has changed and help them decide if they should publish. + // + try { + gitStatus = await exec('git status --porcelain', { cwd }) + } catch (err) { + gitStatus.stderr = err.message + } + + projectUpdates = this.html` +
No changes.
+ ` + + if (!gitStatus.stderr && gitStatus.stdout.length) { + projectUpdates = this.html` +
${gitStatus.stdout}
+ Publish Changes + ` + } + } + return this.html` +

App Settings

${previewWindows} + +

Project Settings

-
- -

The app's primary window is initially hidden.

-
- -
- -

An icon is placed in the omni-present system menu (aka Tray). Clicking it triggers an event.

-
- -
- -

Apps do not appear in the task switcher or on the Dock.

-
+ + +
-
- -

Allow/Disallow fullscreen in application

-
-
- -

Allow/Disallow microphone in application

-
-
- -

Allow/Disallow camera in application

-
-
- -

Allow/Disallow user media (microphone + camera) in application

-
-
- -

Allow/Disallow geolocation in application

-
-
- -

Allow/Disallow notifications in application

-
-
- -

Allow/Disallow sensors in application

-
-
- -

Allow/Disallow clipboard in application

-
-
- -

Allow/Disallow bluetooth in application

-
-
- -

Allow/Disallow data access in application

-
-
- -

Allow/Disallow AirPlay access in application (macOS/iOS) only

-
-
- -

Allow/Disallow HotKey binding registration (desktop only)

-
+ + + + + + + + + + + + +
+ + + + + ${projectUpdates}
` diff --git a/src/components/publish.js b/src/components/publish.js index 384acda..3d33215 100644 --- a/src/components/publish.js +++ b/src/components/publish.js @@ -6,11 +6,63 @@ import { spawn, exec } from 'socket:child_process' import Tonic from '@socketsupply/tonic' import { TonicDialog } from '@socketsupply/components/dialog' +import * as ini from '../lib/ini.js' + export class DialogPublish extends TonicDialog { - async publish (value) { + click (e) { + super.click(e) + + const el = Tonic.match(e.target, '[data-event]') + if (!el) return + + if (el.dataset.event === 'close') { + super.hide() + } + } + + async getProject () { const app = this.props.parent + const currentProject = app.state.currentProject + if (!currentProject) return - console.log(app.socket) + let src + + try { + const fp = path.join(currentProject.id, 'socket.ini') + src = await fs.promises.readFile(fp, 'utf8') + } catch (err) { + const notifications = document.querySelector('#notifications') + notifications?.create({ + type: 'error', + title: 'Error', + message: err.message + }) + + return + } + + let bundleId = ini.get(src, 'meta', 'bundle_identifier') + bundleId = bundleId.replace(/"/g, '') + + const { data: hasProject } = await app.db.projects.has(bundleId) + + if (hasProject) { + const { data: dataProject } = await app.db.projects.get(bundleId) + return dataProject + } + } + + async publish (type, value) { + const app = this.props.parent + const settings = app.state.settings + const dataProject = await this.getProject() + + const opts = { + + } + + const subcluster = app.socket.subclusters.get(dataProject.subclusterId) + const packets = await subcluster.emit(type, value, opts) } async show () { @@ -23,40 +75,28 @@ export class DialogPublish extends TonicDialog { const currentProject = app.state.currentProject const cwd = currentProject?.id - if (!cwd) return this.html`` - - yield this.html` - - ` - - const { data: dataPeer } = await app.db.state.get('peer') - let sharedSecret = this.state.sharedSecret || await Encryption.createId() - - // if there is no subscription for this, create one - if (!dataPeer.config?.clusterId) { - // TODO(@heapwolf): project ids should be agnostic of paths in case the user wants to change the path - await app.db.subscriptions.put(currentProject.id, { - name: currentProject.label, - clusterId, - channelId: scid, - sharedKey, - description: 'The default channel.', - rateLimit: 32, - lastUpdate: Date.now(), - nicks: 1, - unread: 0, - mentions: 0 - }) - } - const notifications = document.querySelector('#notifications') const coTerminal = document.querySelector('app-terminal') + // + // these are cases where the app just isn't initialied yet. + // + if (!notifications || !coTerminal) return this.html`` + if (!currentProject || !cwd) return this.html`` + + // + // these git commands might take a few seconds so show the user a spinner + // + yield this.html`` + let output = { stdout: '' } + let exists = false - // Check if the project is a git directory. + // + // Check if there is a .git directory, if not run git init. + // try { - await fs.promises.stat(path.join(cwd, '.git')) + exists = await fs.promises.stat(path.join(cwd, '.git')) } catch (err) { try { // if not, initialize the directory as a git project @@ -72,18 +112,24 @@ export class DialogPublish extends TonicDialog { } } - // try to get the status of the project + // + // Get the current hash, it will go into packet.usr3 + // try { - output = await exec('git status', { cwd }) + output = await exec('git rev-parse HEAD', { cwd }) } catch (err) { output.stderr = err.message - } - - if (output.stderr) { - coTerminal.error(output.stderr) + coTerminal.writeln(output.stderr) + await this.hide() return this.html`` } + const currentHash = output.stdout.trim() + const commitMessage = '' // TODO(@heapwolf): option user to specify + + // + // Add any files to the repo + // try { output = await exec('git add . --ignore-errors', { cwd }) } catch (err) { @@ -99,10 +145,20 @@ export class DialogPublish extends TonicDialog { coTerminal.info('git add .') coTerminal.writeln(output.stdout) - if (!output.stdout.includes('nothing to commit')) { - // try to commit the code to the project + // + // If there is something to commit... + // + if (output.stdout.includes('nothing to commit') === false) { + // + // Try to commit the changes. + // + const msg = { + parent: currentHash, + message: commitMessage + } + try { - output = await exec('git commit -m "share"', { cwd }) + output = await exec(`git commit -m '${JSON.stringify(msg)}'`, { cwd }) } catch (err) { output.stderr = err.message } @@ -116,21 +172,44 @@ export class DialogPublish extends TonicDialog { coTerminal.info('git commit .') coTerminal.writeln(output.stdout) - try { - output = await exec('git show HEAD', { cwd }) - } catch (err) { - output.stderr = err.message - } - - if (output.stderr) { - await this.hide() - coTerminal.error(output.stderr) - return this.html`` + if (!exists) { + // + // This is the first time, so publish the whole .git directory + // + try { + output = await exec('git bundle create repo.bundle --all', { cwd }) + } catch (err) { + output.stderr = err.message + coTerminal.writeln(output.stderr) + await this.hide() + return this.html`` + } + + coTerminal.info('Publishing bundle') + const data = await fs.promises.readFile(path.join(cwd, 'repo.bundle')) + this.publish('clone', data) // into the background + } else { + // + // Just publish the diff + // + try { + output = await exec(`git format-patch -1 HEAD --stdout`, { cwd }) + } catch (err) { + output.stderr = err.message + } + + if (output.stderr) { + coTerminal.error(output.stderr) + await this.hide() + return this.html`` + } + + coTerminal.info('Publishing patch') + this.publish('patch', Buffer.from(output.stdout)) // into the background } - // Now we're ready to share the diff, which can be applied as a patch when - // received by another user who is subscribing to this user's subcluster. - // this.publish(output.stdout) + const coProperties = document.querySelector('app-properties') + coProperties.reRender() } return this.html` @@ -138,25 +217,11 @@ export class DialogPublish extends TonicDialog { Publish
-

- This is a unique shared secret for ${app.state.currentProject.label}, - share it only with people that you want to access this code. -

- - - +

🎉

+

Success!

-
- + OK
` } diff --git a/src/components/subscribe.js b/src/components/subscribe.js index 34b8343..7ce76aa 100644 --- a/src/components/subscribe.js +++ b/src/components/subscribe.js @@ -31,7 +31,7 @@ export class DialogSubscribe extends TonicDialog { diff --git a/src/css/index.css b/src/css/index.css index b44c74e..68f7c3f 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -11,6 +11,7 @@ body { font-size: 14px; overflow: hidden; background-color: var(--tonic-window); + color: var(--tonic-primary); } body.no-labels section { @@ -21,6 +22,15 @@ body.no-labels label { display: none; } +tonic-accordion h3 { + text-transform: uppercase; + margin: 20px 14px 6px; + color: var(--tonic-primary); + font-family: var(--tonic-body); + font-weight: 100; + font-size: 14px; +} + #inputs { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; @@ -185,7 +195,7 @@ tonic-tab-panel { } #options .option p { - margin-left: 10px; + margin-left: 0px; padding-bottom: 10px; margin-top: 0; border-bottom: 1px solid var(--tonic-border); @@ -259,7 +269,7 @@ header { app-view > header { display: grid; - grid-template-columns: 68px 1fr 34px 250px 34px 1fr 34px 34px; + grid-template-columns: 1fr 34px 250px 34px 1fr; gap: 12px; grid-template-rows: auto; align-content: center; @@ -271,11 +281,6 @@ app-view > header { right: 0; } -app-view > header [platform="darwin"] { - padding-left: 35%; - padding-right: 35%; -} - #split-main { top: 50px; border-top: 1px solid var(--tonic-border); diff --git a/src/css/properties.css b/src/css/properties.css new file mode 100644 index 0000000..560c6bb --- /dev/null +++ b/src/css/properties.css @@ -0,0 +1,31 @@ +app-properties { + overflow: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +app-properties #project-status { + border: 1px solid var(--tonic-border); + padding: 20px 12px; + margin-top: 10px; + background: var(--tonic-background); + overflow: auto; + max-width: 100%; + border-radius: 2px; +} + +app-properties label { + color: var(--tonic-medium, #999); + font-weight: 500; + font: 12px/14px var(--tonic-subheader, 'Arial', sans-serif); + text-transform: uppercase; + margin-top: 20px; + letter-spacing: 1px; +} + +app-properties .pull-right { + place-self: end; +} diff --git a/src/css/publish.css b/src/css/publish.css index 55731cb..b671f5c 100644 --- a/src/css/publish.css +++ b/src/css/publish.css @@ -5,6 +5,9 @@ dialog-publish { min-height: 280px; display: grid; font-family: var(--tonic-monospace); + text-align: center; + app-region: drag; + --app-region: drag; } dialog-publish header { @@ -16,27 +19,23 @@ dialog-publish header { } dialog-publish footer { - height: 50px; + height: 60px; display: grid; grid-template-columns: 1fr auto; - gap: 10px; - padding: 0 16px; + gap: 12px; } -dialog-publish .centered { - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - display: grid; - justify-content: center; - align-content: center; +dialog-publish tonic-button { + app-region: unset; + --app-region: unset; + place-self: center end; } -dialog-publish .centered .message { - display: inline-block; - width: 80%; - margin: auto; +dialog-publish .centered p { + margin: 0; } dialog-publish main { - padding: 16px 16%; + justify-content: center; + align-content: center; } diff --git a/src/css/theme.css b/src/css/theme.css index 208f9dc..0c86e19 100644 --- a/src/css/theme.css +++ b/src/css/theme.css @@ -59,7 +59,7 @@ body { --tonic-button-background-focus: rgba(84, 84, 84, 1); --tonic-input-text: rgba(255, 255, 255, 1); --tonic-input-text-hover: rgba(255, 255, 255, 1); - --tonic-input-background: rgba(45, 45, 45, 1); + --tonic-input-background: rgba(40, 40, 40, 1); --tonic-input-background-focus: rgba(30, 30, 30, 1); --tonic-input-border: rgba(80, 80, 80, 1); --tonic-input-border-hover: rgba(105, 105, 105, 1); diff --git a/src/css/tonic-overrides.css b/src/css/tonic-overrides.css index 517ae06..8673fcb 100644 --- a/src/css/tonic-overrides.css +++ b/src/css/tonic-overrides.css @@ -31,22 +31,8 @@ tonic-split .tonic--split-handle { z-index: 20 !important; } -.tonic--accordion-panel { - display: grid; -} - -tonic-accordion-section:not(:last-of-type) { - border: none !important; -} - -tonic-accordion-section .tonic--accordion-header button .tonic--label { - font-family: var(--tonic-body); - color: var(--tonic-primary); - font-weight: 100; -} - tonic-accordion-section .tonic--accordion-panel { - padding: 10px 30px 20px 20px !important; + padding: 0px 20px 20px 14px !important } .tonic--split-handle.tonic--split-horizontal { @@ -68,6 +54,8 @@ tonic-toggle { tonic-accordion { border: none !important; + cursor: default; + padding: 2px 10px; } tonic-accordion-section .tonic--accordion-header .tonic--arrow:before { @@ -75,6 +63,40 @@ tonic-accordion-section .tonic--accordion-header .tonic--arrow:before { height: 6px !important; } +tonic-accordion-section:not(:last-of-type) { + border: none !important; +} + +tonic-accordion-section .tonic--accordion-header button .tonic--label { + font-family: var(--tonic-body); + padding: 14px; + color: var(--tonic-info); + font-weight: 100; +} + +tonic-accordion-section .tonic--accordion-header button[aria-expanded="true"] .tonic--label { + text-decoration: underline; + text-underline-offset: 2px; + color: var(--tonic-accent); +} + +tonic-accordion-section .tonic--accordion-header button { + cursor: pointer; + padding: 14px 0 !important; +} + +.tonic--accordion-panel { + display: grid; +} + +tonic-checkbox > div { + padding: 6px 0 !important; +} + +tonic-checkbox .tonic--icon { + margin: 4px 0 0px 1px !important; +} + tonic-range { padding: 8px 0 !important; } diff --git a/src/index.html b/src/index.html index c92a132..4281aca 100644 --- a/src/index.html +++ b/src/index.html @@ -17,6 +17,7 @@ > + diff --git a/src/index.js b/src/index.js index b7808e3..e5b6e64 100644 --- a/src/index.js +++ b/src/index.js @@ -292,39 +292,39 @@ class AppView extends Tonic { }) } - const { data: dataSubscriptions } = await this.db.channels.readAll() + const { data: dataProjects } = await this.db.projects.readAll() - for (const [channelId, channel] of dataSubscriptions.entries()) { - if (!channel.sharedKey) continue - if (socket.subclusters.get(channelId)) continue + for (const [projectId, project] of dataProjects.entries()) { + if (!project.sharedKey) continue + if (socket.subclusters.get(project.subclusterId)) continue - const subcluster = await socket.subcluster({ sharedKey: channel.sharedKey }) + const subcluster = await socket.subcluster({ sharedKey: project.sharedKey }) + + subcluster.on('patch', async (value, packet) => { + console.log('GOT PATCH!', value, packet) + if (!packet.verified) return // gtfoa + if (packet.index !== -1) return // not interested - const onMessage = async (value, packet) => { const pid = Buffer.from(packet.packetId).toString('hex') const scid = Buffer.from(packet.subclusterId).toString('base64') - const key = [channelId, pid].join('\xFF') + const key = [projectId, pid].join('\xFF') const { data: hasPacket } = await this.db.patches.has(key) if (hasPacket) return - - } + }) - subcluster.on('message', (value, packet) => { + subcluster.on('tag', async (value, packet) => { if (!packet.verified) return // gtfoa if (packet.index !== -1) return // not interested - // messages must be parsable - try { value = JSON.parse(value) } catch { return } - - // messages must have content - if (!value || !value.content) return + const pid = Buffer.from(packet.packetId).toString('hex') + const scid = Buffer.from(packet.subclusterId).toString('base64') + const key = [projectId, pid].join('\xFF') - // messages must have a type - if (typeof value.type !== 'string') return + const { data: hasPacket } = await this.db.patches.has(key) + if (hasPacket) return - onMessage(value, packet) }) } } @@ -336,8 +336,13 @@ class AppView extends Tonic { } this.db = { - channels: await Database.open('channels'), + projects: await Database.open('projects'), + // patches are the primary type of data associated with a + // channel a patch can be reviewed, applied or discarded. + // in the future this could include other things like build + // artifacts, messages, comments, etc. patches: await Database.open('patches'), + // state contains state data for the underlying peer. state: await Database.open('state') } @@ -505,6 +510,7 @@ class AppView extends Tonic { File: New Project: n + CommandOrControl + Add Shared Project: G + CommandOrControl --- Reset Demo Project: _ ; @@ -573,6 +579,12 @@ class AppView extends Tonic { break } + case 'Add Shared Project': { + const coDialogSubscribe = document.querySelector('dialog-subscribe') + if (coDialogSubscribe) coDialogSubscribe.show() + break + } + case 'Toggle Properties': { document.querySelector('#split-main').toggle('right') break @@ -646,14 +658,7 @@ class AppView extends Tonic { this.exportProject() } - if (event === 'subscribe') { - const coDialogSubscribe = document.querySelector('dialog-subscribe') - if (coDialogSubscribe) coDialogSubscribe.show() - } - if (event === 'publish') { - const coDialogPublish = document.querySelector('dialog-publish') - if (coDialogPublish) coDialogPublish.show() } } @@ -671,7 +676,6 @@ class AppView extends Tonic { return this.html`
- @@ -688,26 +692,9 @@ class AppView extends Tonic { - - - - -
- +