mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138d9d63ab | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a |
@@ -135,32 +135,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Backport electron-builder#10172 to the pinned build tool only.
|
|
||||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
|
||||||
- name: Repair temporary keychain password in signing tool
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'KEYCHAIN_FIX'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
|
||||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
|
||||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
|
||||||
}
|
|
||||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
|
||||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
|
||||||
const replacements = [
|
|
||||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
|
||||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
|
||||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
|
||||||
];
|
|
||||||
for (const [before, after] of replacements) {
|
|
||||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
|
||||||
source = source.replace(before, after);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(sourcePath, source);
|
|
||||||
KEYCHAIN_FIX
|
|
||||||
|
|
||||||
- name: Set desktop package version from tag
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+11
-1
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
|||||||
|
|
||||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||||
|
|
||||||
## Inverted timeline selection
|
## Inverted timeline scrolling
|
||||||
|
|
||||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||||
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
|||||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||||
|
|
||||||
|
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
|
||||||
|
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
|
||||||
|
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/app/android
|
||||||
|
./gradlew :paseo-scroll:connectedDebugAndroidTest \
|
||||||
|
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||||
|
```
|
||||||
|
|
||||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
|
|||||||
|
|
||||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||||
operations; requesting this feature gives update guidance.
|
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||||
|
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||||
|
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||||
|
`replaceIdentity`, or A restarts.
|
||||||
|
|
||||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
|||||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||||
# disable the hardened runtime that requires a matching signature.
|
# disable the hardened runtime that requires a matching signature.
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||||
../../node_modules/.bin/electron-builder \
|
npm exec --offline -- electron-builder \
|
||||||
--config electron-builder.yml \
|
--config electron-builder.yml \
|
||||||
--dir \
|
--dir \
|
||||||
--mac \
|
--mac \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||||
|
|||||||
Generated
+1018
-1295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package sh.paseo.scroll
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
|
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||||
|
import com.facebook.react.uimanager.ReactStylesDiffMap
|
||||||
|
import com.facebook.react.uimanager.ThemedReactContext
|
||||||
|
import com.facebook.react.views.scroll.ReactScrollView
|
||||||
|
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||||
|
import com.facebook.soloader.SoLoader
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
class NestedScrollActivity : Activity()
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class NestedScrollTest {
|
||||||
|
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||||
|
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
|
||||||
|
child.scrollTo(0, 0)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
|
||||||
|
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||||
|
assertEquals(before + 100, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 100)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
|
||||||
|
assertEquals(100, child.scrollY)
|
||||||
|
assertEquals(before, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
parent.scrollTo(0, before + 100)
|
||||||
|
assertEquals(before + 100, parent.scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
|
||||||
|
|
||||||
|
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
|
||||||
|
|
||||||
|
private fun checkFling(inverted: Boolean) = withNestedScroll(
|
||||||
|
inverted = inverted,
|
||||||
|
afterFrames = { parent ->
|
||||||
|
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
|
||||||
|
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
|
||||||
|
}
|
||||||
|
) { parent, child ->
|
||||||
|
parent.scrollTo(0, 400)
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withNestedScroll(
|
||||||
|
inverted: Boolean,
|
||||||
|
afterFrames: ((ReactScrollView) -> Unit)? = null,
|
||||||
|
check: (ReactScrollView, ReactScrollView) -> Unit
|
||||||
|
) {
|
||||||
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
val activity = instrumentation.startActivitySync(
|
||||||
|
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
lateinit var parent: ReactScrollView
|
||||||
|
try {
|
||||||
|
instrumentation.runOnMainSync {
|
||||||
|
val appContext = instrumentation.targetContext
|
||||||
|
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
|
val reactContext = ScrollTestContext(appContext)
|
||||||
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
|
parent = manager.createViewInstance(context)
|
||||||
|
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
|
||||||
|
val panelContent = View(context)
|
||||||
|
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
|
||||||
|
val content = FrameLayout(context)
|
||||||
|
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
|
||||||
|
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
|
||||||
|
panelContent.measure(exact(300), exact(800))
|
||||||
|
panelContent.layout(0, 0, 300, 800)
|
||||||
|
child.measure(exact(300), exact(200))
|
||||||
|
child.layout(0, 500, 300, 700)
|
||||||
|
content.measure(exact(300), exact(2000))
|
||||||
|
content.layout(0, 0, 300, 2000)
|
||||||
|
parent.measure(exact(300), exact(400))
|
||||||
|
parent.layout(0, 0, 300, 400)
|
||||||
|
// The same transform React Native applies to an inverted FlatList on Android.
|
||||||
|
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
|
||||||
|
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
|
||||||
|
)))
|
||||||
|
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
|
||||||
|
parent.scrollTo(0, 400)
|
||||||
|
check(parent, child)
|
||||||
|
}
|
||||||
|
if (afterFrames != null) {
|
||||||
|
// Let the framework's real fling animation advance, with an ordinary-parent control.
|
||||||
|
SystemClock.sleep(500)
|
||||||
|
instrumentation.runOnMainSync { afterFrames(parent) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
instrumentation.runOnMainSync { activity.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||||
|
}
|
||||||
+1
-2
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.facebook.react.bridge.BridgeReactContext
|
|
||||||
import com.facebook.react.bridge.JavaOnlyArray
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
import com.facebook.react.bridge.JavaOnlyMap
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
|
|||||||
val appContext = instrumentation.targetContext
|
val appContext = instrumentation.targetContext
|
||||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
val reactContext = BridgeReactContext(appContext)
|
val reactContext = ScrollTestContext(appContext)
|
||||||
val context = ThemedReactContext(reactContext, appContext)
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
val scroll = manager.createViewInstance(context)
|
val scroll = manager.createViewInstance(context)
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package sh.paseo.scroll;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.facebook.react.bridge.BridgeReactContext;
|
||||||
|
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||||
|
|
||||||
|
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||||
|
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||||
|
ScrollTestContext(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EventDispatcher getEventDispatcher() {
|
||||||
|
return BlackHoleEventDispatcher.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -2,6 +2,7 @@ package sh.paseo.scroll
|
|||||||
|
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
|||||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||||
private val transformValues = FloatArray(9)
|
private val transformValues = FloatArray(9)
|
||||||
|
|
||||||
|
private val isInverted: Boolean
|
||||||
|
get() {
|
||||||
|
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||||
|
matrix.getValues(transformValues)
|
||||||
|
return transformValues[Matrix.MSCALE_Y] < 0
|
||||||
|
}
|
||||||
|
|
||||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||||
// movement to user gestures and explicit scroll commands.
|
// movement to user gestures and explicit scroll commands.
|
||||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
if (isInverted) return 0
|
||||||
matrix.getValues(transformValues)
|
|
||||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
|
||||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A nested panel's remaining delta uses its own visual direction. Consuming it in
|
||||||
|
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
|
||||||
|
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
|
||||||
|
if (isInverted) return
|
||||||
|
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
|
||||||
|
if (isInverted && !consumed) return true
|
||||||
|
return super.onNestedFling(target, velocityX, velocityY, consumed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
|
|||||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||||
{ name: "DA2", bytes: "\x1b[>c" },
|
{ name: "DA2", bytes: "\x1b[>c" },
|
||||||
{ name: "DA3", bytes: "\x1b[=c" },
|
{ name: "DA3", bytes: "\x1b[=c" },
|
||||||
|
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
||||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||||
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
||||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||||
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
||||||
|
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
|
||||||
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
||||||
|
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||||
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
{ prefix: "?", intermediates: "$", final: "p" },
|
{ prefix: "?", intermediates: "$", final: "p" },
|
||||||
() => true,
|
() => true,
|
||||||
);
|
);
|
||||||
|
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
||||||
for (const code of [10, 11, 12]) {
|
for (const code of [10, 11, 12]) {
|
||||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
|
|||||||
}
|
}
|
||||||
export function createHostClient(
|
export function createHostClient(
|
||||||
connection: string,
|
connection: string,
|
||||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
||||||
budget?: HostTransportBudget,
|
budget?: HostTransportBudget,
|
||||||
): DaemonClient {
|
): DaemonClient {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
const { webSocketAgent, ...clientConfig } = config;
|
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...clientConfig,
|
...clientConfig,
|
||||||
url: "ws://host-connector/ws",
|
url: "ws://host-connector/ws",
|
||||||
...(target.password ? { password: target.password } : {}),
|
...(target.password ? { password: target.password } : {}),
|
||||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
transportFactory:
|
||||||
|
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
"electron-builder": "26.8.1",
|
"electron-builder": "26.18.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unzip-crx-3": "^0.2.0",
|
"unzip-crx-3": "^0.2.0",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
|
|||||||
@@ -784,6 +784,211 @@ test("old capabilities fail before execution and authentication errors recover t
|
|||||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function updatableDestination() {
|
||||||
|
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
|
||||||
|
let holding = false;
|
||||||
|
const held: (() => void)[] = [];
|
||||||
|
let connections = 0;
|
||||||
|
const http = createServer();
|
||||||
|
const ws = new WebSocketServer({ server: http });
|
||||||
|
ws.on("connection", (socket) => {
|
||||||
|
connections++;
|
||||||
|
socket.on("message", (data) => {
|
||||||
|
const frame = JSON.parse(data.toString());
|
||||||
|
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
|
||||||
|
if (frame.type !== "hello") return;
|
||||||
|
const reply = () =>
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_updated",
|
||||||
|
hostname: "updated",
|
||||||
|
version: "0.12.0",
|
||||||
|
...(features ? { features } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (holding) held.push(reply);
|
||||||
|
else reply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
http.listen(0, "127.0.0.1");
|
||||||
|
await once(http, "listening");
|
||||||
|
cleanup.push(async () => {
|
||||||
|
for (const socket of ws.clients) socket.terminate();
|
||||||
|
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||||
|
});
|
||||||
|
const address = http.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing destination port");
|
||||||
|
return {
|
||||||
|
port: address.port,
|
||||||
|
connections: () => connections,
|
||||||
|
hold: () => {
|
||||||
|
holding = true;
|
||||||
|
},
|
||||||
|
update: () => {
|
||||||
|
features = { connectedHosts: true, hostMultiplexing: true };
|
||||||
|
holding = false;
|
||||||
|
for (const reply of held.splice(0)) reply();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
|
||||||
|
let retryAt = "";
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const row = (await paseo.hosts.list()).hosts[0];
|
||||||
|
retryAt = row?.nextRetryAt ?? "";
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
)
|
||||||
|
.toMatchObject({
|
||||||
|
status: "offline",
|
||||||
|
nextRetryAt: expect.any(String),
|
||||||
|
error: { code: "unsupported_capability" },
|
||||||
|
});
|
||||||
|
return Date.parse(retryAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
|
||||||
|
const destination = await updatableDestination();
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||||
|
await capabilityRetry(paseo);
|
||||||
|
destination.hold();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
|
||||||
|
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
|
||||||
|
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
|
||||||
|
code: "unsupported_capability",
|
||||||
|
});
|
||||||
|
destination.update();
|
||||||
|
expect((await online(paseo)).hosts[0]).toMatchObject({
|
||||||
|
registrationId: host.registrationId,
|
||||||
|
serverId: "srv_updated",
|
||||||
|
nextRetryAt: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
|
||||||
|
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disabling or removing a host cancels its pending capability retry", async () => {
|
||||||
|
const destination = await updatableDestination();
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
||||||
|
let retryAt = await capabilityRetry(paseo);
|
||||||
|
let connections = destination.connections();
|
||||||
|
await paseo.hosts.update(host.registrationId, { enabled: false });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||||
|
expect(destination.connections()).toBe(connections);
|
||||||
|
await paseo.hosts.update(host.registrationId, { enabled: true });
|
||||||
|
retryAt = await capabilityRetry(paseo);
|
||||||
|
connections = destination.connections();
|
||||||
|
await paseo.hosts.remove(host.registrationId);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
||||||
|
expect(destination.connections()).toBe(connections);
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDaemonChannel,
|
||||||
|
exportPublicKey,
|
||||||
|
generateKeyPair,
|
||||||
|
openDaemonSecureChannel,
|
||||||
|
type Transport,
|
||||||
|
} from "@getpaseo/relay/e2ee";
|
||||||
|
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
|
||||||
|
const keys = generateKeyPair();
|
||||||
|
const legacyReceived: unknown[] = [];
|
||||||
|
let connections = 0;
|
||||||
|
let retryClosed = false;
|
||||||
|
const http = createServer();
|
||||||
|
const ws = new WebSocketServer({ server: http });
|
||||||
|
ws.on("connection", (socket) => {
|
||||||
|
connections++;
|
||||||
|
const wire: Transport = {
|
||||||
|
send: (data) => socket.send(data),
|
||||||
|
close: (code, reason) => socket.close(code, reason),
|
||||||
|
onmessage: null,
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
socket.on("message", (data, isBinary) => {
|
||||||
|
const bytes = Buffer.from(data as Buffer);
|
||||||
|
wire.onmessage?.({
|
||||||
|
data: isBinary
|
||||||
|
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||||
|
: bytes.toString(),
|
||||||
|
isBinary,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
|
||||||
|
if (connections > 1) {
|
||||||
|
socket.on("close", () => {
|
||||||
|
retryClosed = true;
|
||||||
|
});
|
||||||
|
void createDaemonChannel(wire, keys, {
|
||||||
|
onmessage: (data) => legacyReceived.push(data),
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = openDaemonSecureChannel(wire, {
|
||||||
|
daemonKeyPair: keys,
|
||||||
|
events: {
|
||||||
|
onmessage: (data) => {
|
||||||
|
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
|
||||||
|
void channel.then((secure) =>
|
||||||
|
secure.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv_relayed",
|
||||||
|
hostname: "relayed",
|
||||||
|
version: "0.12.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void channel.catch(() => {});
|
||||||
|
});
|
||||||
|
http.listen(0, "127.0.0.1");
|
||||||
|
await once(http, "listening");
|
||||||
|
cleanup.push(async () => {
|
||||||
|
for (const socket of ws.clients) socket.terminate();
|
||||||
|
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||||
|
});
|
||||||
|
const address = http.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing relay port");
|
||||||
|
const a = await daemon();
|
||||||
|
const { paseo } = await api(a);
|
||||||
|
await paseo.hosts.add({
|
||||||
|
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
|
||||||
|
});
|
||||||
|
await capabilityRetry(paseo);
|
||||||
|
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
|
||||||
|
expect(legacyReceived).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||||
const reservation = createServer();
|
const reservation = createServer();
|
||||||
reservation.listen(0, "127.0.0.1");
|
reservation.listen(0, "127.0.0.1");
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
createHostClient,
|
createHostClient,
|
||||||
createHostChannelTransport,
|
createHostChannelTransport,
|
||||||
|
createHostTransportFactory,
|
||||||
parseHostTarget,
|
parseHostTarget,
|
||||||
HostTransportBudget,
|
HostTransportBudget,
|
||||||
} from "@getpaseo/client/node";
|
} from "@getpaseo/client/node";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
||||||
import {
|
import {
|
||||||
HostSummarySchema,
|
HostSummarySchema,
|
||||||
HostAddSchema,
|
HostAddSchema,
|
||||||
@@ -235,23 +237,30 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === id)
|
if (route.registrationId === id)
|
||||||
route.close(code, "Host access was disabled, replaced or removed");
|
route.close(code, "Host access was disabled, replaced or removed");
|
||||||
}
|
}
|
||||||
private start(row: Saved): void {
|
private start(
|
||||||
|
row: Saved,
|
||||||
|
resume?: { failures: number; transports: DaemonTransportFactory },
|
||||||
|
): void {
|
||||||
if (!row.enabled || this.closed) return;
|
if (!row.enabled || this.closed) return;
|
||||||
let stopped = false;
|
const transports =
|
||||||
const client = createHostClient(
|
resume?.transports ??
|
||||||
row.connection,
|
createHostTransportFactory(row.connection, this.budget, {
|
||||||
{
|
|
||||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
|
||||||
connectTimeoutMs: 10000,
|
|
||||||
webSocketAgent: webSocketProxyAgent,
|
webSocketAgent: webSocketProxyAgent,
|
||||||
},
|
});
|
||||||
this.budget,
|
let stopped = false;
|
||||||
);
|
let attempts = resume?.failures ?? 0;
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const client = createHostClient(row.connection, {
|
||||||
|
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||||
|
connectTimeoutMs: 10000,
|
||||||
|
transportFactory: transports,
|
||||||
|
});
|
||||||
const live: Live = {
|
const live: Live = {
|
||||||
client,
|
client,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
clearTimeout(retry);
|
||||||
void client.close();
|
void client.close();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -285,7 +294,7 @@ export class ConnectedHosts {
|
|||||||
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
||||||
throw new HostError(
|
throw new HostError(
|
||||||
"unsupported_capability",
|
"unsupported_capability",
|
||||||
"Update the destination daemon to use connected hosts.",
|
"Update the destination daemon to use connected hosts; retrying automatically.",
|
||||||
);
|
);
|
||||||
// Pin and persist under the same writer as management to prevent discovery races.
|
// Pin and persist under the same writer as management to prevent discovery races.
|
||||||
await this.mutate(async () => {
|
await this.mutate(async () => {
|
||||||
@@ -304,16 +313,22 @@ export class ConnectedHosts {
|
|||||||
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
||||||
Object.assign(row, next);
|
Object.assign(row, next);
|
||||||
live.accepted = true;
|
live.accepted = true;
|
||||||
|
attempts = 0;
|
||||||
this.changed();
|
this.changed();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
row.status = "error";
|
|
||||||
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Host connection failed",
|
message: "Host connection failed",
|
||||||
};
|
};
|
||||||
row.nextRetryAt = null;
|
if (terminalFailures.has(row.error.code)) {
|
||||||
|
row.status = "error";
|
||||||
|
row.nextRetryAt = null;
|
||||||
|
} else {
|
||||||
|
stopped = true;
|
||||||
|
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
|
||||||
|
}
|
||||||
this.changed();
|
this.changed();
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
@@ -329,18 +344,17 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === row.registrationId) route.close();
|
if (route.registrationId === row.registrationId) route.close();
|
||||||
row.status = state.status === "connecting" ? "connecting" : "offline";
|
row.status = state.status === "connecting" ? "connecting" : "offline";
|
||||||
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
||||||
row.error =
|
if (state.status === "disconnected")
|
||||||
state.status === "disconnected"
|
row.error = connectionFailure(
|
||||||
? (connectionFailure(
|
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
code: state.failureCode,
|
||||||
code: state.failureCode,
|
}),
|
||||||
}),
|
client.authFailureReason !== null,
|
||||||
client.authFailureReason !== null,
|
) ?? {
|
||||||
) ?? {
|
code: "host_offline",
|
||||||
code: "host_offline",
|
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
};
|
||||||
})
|
else if (row.error?.code !== "unsupported_capability") row.error = null;
|
||||||
: null;
|
|
||||||
if (row.error?.code === "authentication_failed") {
|
if (row.error?.code === "authentication_failed") {
|
||||||
row.status = "error";
|
row.status = "error";
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
@@ -351,6 +365,18 @@ export class ConnectedHosts {
|
|||||||
});
|
});
|
||||||
void client.connect().catch(() => {});
|
void client.connect().catch(() => {});
|
||||||
}
|
}
|
||||||
|
private scheduleAcceptanceRetry(
|
||||||
|
row: Saved,
|
||||||
|
failures: number,
|
||||||
|
transports: DaemonTransportFactory,
|
||||||
|
): ReturnType<typeof setTimeout> {
|
||||||
|
const delay = acceptanceRetryDelayMs(failures);
|
||||||
|
row.status = "offline";
|
||||||
|
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
|
||||||
|
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
|
||||||
|
retry.unref();
|
||||||
|
return retry;
|
||||||
|
}
|
||||||
async openRoute(input: {
|
async openRoute(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
@@ -531,6 +557,20 @@ export class ConnectedHosts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCEPTANCE_RETRY_BASE_MS = 1500;
|
||||||
|
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
function acceptanceRetryDelayMs(failures: number): number {
|
||||||
|
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalFailures = new Set([
|
||||||
|
"server_id_mismatch",
|
||||||
|
"self_host",
|
||||||
|
"duplicate_host",
|
||||||
|
"authentication_failed",
|
||||||
|
]);
|
||||||
|
|
||||||
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||||
|
|||||||
@@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
|||||||
60_000,
|
60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each(
|
test.each(["single", "batch", "rename"] as const)(
|
||||||
(["uncommitted", "base"] as const).flatMap((mode) =>
|
"%s diff includes hunks from a repository subdirectory before and after commit",
|
||||||
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
async (shape) => {
|
||||||
),
|
|
||||||
)(
|
|
||||||
"$mode $shape diff includes hunks from a repository subdirectory",
|
|
||||||
async ({ mode, shape }) => {
|
|
||||||
const ctx = await createDaemonTestContext();
|
const ctx = await createDaemonTestContext();
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||||
|
|
||||||
@@ -112,9 +108,7 @@ test.each(
|
|||||||
}
|
}
|
||||||
git(cwd, "add", ".");
|
git(cwd, "add", ".");
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||||
if (mode === "base") {
|
git(cwd, "checkout", "-b", "feature");
|
||||||
git(cwd, "checkout", "-b", "feature");
|
|
||||||
}
|
|
||||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||||
if (shape === "rename") {
|
if (shape === "rename") {
|
||||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||||
@@ -123,36 +117,38 @@ test.each(
|
|||||||
if (shape === "batch") {
|
if (shape === "batch") {
|
||||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||||
}
|
}
|
||||||
if (mode === "base") {
|
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
|
||||||
git(cwd, "add", ".");
|
const compare = { mode, baseRef: "main" };
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||||
|
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||||
|
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
||||||
|
const liveDiff = await subscription.ready;
|
||||||
|
await subscription.release();
|
||||||
|
|
||||||
|
expect(rootDiff.error).toBeNull();
|
||||||
|
expect(subdirectoryDiff.error).toBeNull();
|
||||||
|
expect(liveDiff.error).toBeNull();
|
||||||
|
expect(liveDiff.files).toEqual(rootDiff.files);
|
||||||
|
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
||||||
|
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
||||||
|
expect(file).toMatchObject({
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
||||||
|
});
|
||||||
|
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "remove", content: "before" }),
|
||||||
|
expect.objectContaining({ type: "add", content: "after" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
const compare = { mode, baseRef: "main" };
|
await expectSubdirectoryDiff("uncommitted");
|
||||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
git(cwd, "add", ".");
|
||||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||||
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
await expectSubdirectoryDiff("base");
|
||||||
const liveDiff = await subscription.ready;
|
|
||||||
await subscription.release();
|
|
||||||
|
|
||||||
expect(rootDiff.error).toBeNull();
|
|
||||||
expect(subdirectoryDiff.error).toBeNull();
|
|
||||||
expect(liveDiff.error).toBeNull();
|
|
||||||
expect(liveDiff.files).toEqual(rootDiff.files);
|
|
||||||
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
|
||||||
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
|
||||||
expect(file).toMatchObject({
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
|
||||||
});
|
|
||||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({ type: "remove", content: "before" }),
|
|
||||||
expect.objectContaining({ type: "add", content: "after" }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
|
|||||||
import { PluginService } from "./index.js";
|
import { PluginService } from "./index.js";
|
||||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||||
import { ManagedPluginSources } from "./managed-source.js";
|
import { ManagedPluginSources } from "./managed-source.js";
|
||||||
|
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||||
import {
|
import {
|
||||||
startNpmRegistry,
|
startNpmRegistry,
|
||||||
npmPluginPackages,
|
npmPluginPackages,
|
||||||
@@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
|||||||
return { runtime, started, releaseStart, starts };
|
return { runtime, started, releaseStart, starts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
|
||||||
|
const gates = new Map<string, PromiseWithResolvers<void>>();
|
||||||
|
const gate = (pluginId: string) => {
|
||||||
|
const existing = gates.get(pluginId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = Promise.withResolvers<void>();
|
||||||
|
gates.set(pluginId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
const begun: string[] = [];
|
||||||
|
const running = new Set<string>();
|
||||||
|
const runtime: TestPluginRuntime = {
|
||||||
|
...emptyUsageRuntime,
|
||||||
|
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||||
|
invoke: async () => undefined,
|
||||||
|
getLogs: () => [],
|
||||||
|
clearLogs: () => undefined,
|
||||||
|
getProviderRegistrations: (pluginId) =>
|
||||||
|
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
|
||||||
|
startPlugin: async (pluginId) => {
|
||||||
|
begun.push(pluginId);
|
||||||
|
await gate(pluginId).promise;
|
||||||
|
running.add(pluginId);
|
||||||
|
},
|
||||||
|
stopPluginById: async (pluginId) => running.delete(pluginId),
|
||||||
|
stopAll: async () => {
|
||||||
|
running.clear();
|
||||||
|
},
|
||||||
|
subscribe: () => () => undefined,
|
||||||
|
bindPaseoSessionHost: () => undefined,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
begun,
|
||||||
|
finish: (pluginId: string) => gate(pluginId).resolve(),
|
||||||
|
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const unsubscribe = service.subscribe((notified) => {
|
||||||
|
if (notified !== pluginId) return;
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("PluginService", () => {
|
describe("PluginService", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ shadow: false, runtime: "subprocess" },
|
{ shadow: false, runtime: "subprocess" },
|
||||||
@@ -372,15 +422,127 @@ describe("PluginService", () => {
|
|||||||
"startup-first": { source: "directory", path: first },
|
"startup-first": { source: "directory", path: first },
|
||||||
"startup-second": { source: "directory", path: second },
|
"startup-second": { source: "directory", path: second },
|
||||||
});
|
});
|
||||||
const snapshots: string[][] = [];
|
const notifications: { pluginId: string; running: boolean }[] = [];
|
||||||
service.subscribe(() => snapshots.push(catalogIds(service)));
|
service.subscribe((pluginId) =>
|
||||||
|
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
|
||||||
|
);
|
||||||
|
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
|
expect(notifications).toEqual([
|
||||||
|
{ pluginId: "startup-first", running: true },
|
||||||
|
{ pluginId: "startup-second", running: true },
|
||||||
|
]);
|
||||||
|
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
|
||||||
await service.stopAllPlugins();
|
await service.stopAllPlugins();
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("starts configured plugins concurrently and publishes them in config order", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime();
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
alpha: { source: "directory", path: "/plugins/alpha" },
|
||||||
|
beta: { source: "directory", path: "/plugins/beta" },
|
||||||
|
gamma: { source: "directory", path: "/plugins/gamma" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const notified: string[] = [];
|
||||||
|
service.subscribe((pluginId) => notified.push(pluginId));
|
||||||
|
let started = false;
|
||||||
|
const starting = (async () => {
|
||||||
|
await service.start();
|
||||||
|
started = true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
|
||||||
|
gated.finish("gamma");
|
||||||
|
const alphaPublished = nextNotification(service, "alpha");
|
||||||
|
gated.finish("alpha");
|
||||||
|
await alphaPublished;
|
||||||
|
expect(notified).toEqual(["alpha"]);
|
||||||
|
expect(started).toBe(false);
|
||||||
|
|
||||||
|
gated.finish("beta");
|
||||||
|
await starting;
|
||||||
|
expect(notified).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the other configured plugins when one fails while they are still starting", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime();
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
broken: { source: "directory", path: "/plugins/broken" },
|
||||||
|
healthy: { source: "directory", path: "/plugins/healthy" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const notified: string[] = [];
|
||||||
|
service.subscribe((pluginId) => notified.push(pluginId));
|
||||||
|
const starting = service.start();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
|
||||||
|
const brokenPublished = nextNotification(service, "broken");
|
||||||
|
gated.fail("broken", new Error("startup exploded"));
|
||||||
|
await brokenPublished;
|
||||||
|
gated.finish("healthy");
|
||||||
|
await starting;
|
||||||
|
|
||||||
|
expect(notified).toEqual(["broken", "healthy"]);
|
||||||
|
expect(catalogIds(service)).toEqual(["healthy"]);
|
||||||
|
expect(await service.listPlugins()).toMatchObject([
|
||||||
|
{ id: "broken", status: "failed", error: "startup exploded" },
|
||||||
|
{ id: "healthy", status: "running" },
|
||||||
|
]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
|
||||||
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
|
roots.push(home);
|
||||||
|
const gated = createGatedRuntime({
|
||||||
|
first: [{ id: "shared-agent", label: "First agent" }],
|
||||||
|
second: [{ id: "shared-agent", label: "Second agent" }],
|
||||||
|
});
|
||||||
|
const service = createService(
|
||||||
|
home,
|
||||||
|
{
|
||||||
|
first: { source: "directory", path: "/plugins/first" },
|
||||||
|
second: { source: "directory", path: "/plugins/second" },
|
||||||
|
},
|
||||||
|
{ runtime: gated.runtime },
|
||||||
|
);
|
||||||
|
const starting = service.start();
|
||||||
|
|
||||||
|
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
|
||||||
|
gated.finish("second");
|
||||||
|
gated.finish("first");
|
||||||
|
await starting;
|
||||||
|
|
||||||
|
expect(service.getProviderRegistrations()).toMatchObject([
|
||||||
|
{ id: "shared-agent", label: "First agent" },
|
||||||
|
]);
|
||||||
|
expect(catalogIds(service)).toEqual(["first"]);
|
||||||
|
expect(await service.listPlugins()).toMatchObject([
|
||||||
|
{ id: "first", status: "running" },
|
||||||
|
{
|
||||||
|
id: "second",
|
||||||
|
status: "failed",
|
||||||
|
error: 'Plugin second cannot register provider ID "shared-agent" twice',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
roots.push(home);
|
roots.push(home);
|
||||||
|
|||||||
@@ -230,11 +230,18 @@ export class PluginService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (config.pluginsEnabled === true) {
|
if (config.pluginsEnabled === true) {
|
||||||
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
let earlierPublished: Promise<unknown> = Promise.resolve();
|
||||||
if (source.enabled === false) continue;
|
await Promise.all(
|
||||||
await this.startConfigured(pluginId);
|
Object.entries(config.plugins ?? {})
|
||||||
this.notify(pluginId);
|
.filter(([, source]) => source.enabled !== false)
|
||||||
}
|
.map(async ([pluginId]) => {
|
||||||
|
const publishTurn = earlierPublished;
|
||||||
|
const starting = this.startConfigured(pluginId, publishTurn);
|
||||||
|
earlierPublished = Promise.all([publishTurn, starting]);
|
||||||
|
await starting;
|
||||||
|
this.notify(pluginId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
||||||
this.handleGlobalSwitch(value === true);
|
this.handleGlobalSwitch(value === true);
|
||||||
@@ -560,12 +567,12 @@ export class PluginService {
|
|||||||
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConfigured(pluginId: string): Promise<void> {
|
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
|
||||||
const source = this.configStore.get().plugins?.[pluginId];
|
const source = this.configStore.get().plugins?.[pluginId];
|
||||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||||
this.errors.delete(pluginId);
|
this.errors.delete(pluginId);
|
||||||
try {
|
try {
|
||||||
await this.startPlugin(pluginId, source.path);
|
await this.startPlugin(pluginId, source.path, publishTurn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||||
}
|
}
|
||||||
@@ -594,8 +601,13 @@ export class PluginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
private async startPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
sourcePath: string,
|
||||||
|
publishTurn?: Promise<unknown>,
|
||||||
|
): Promise<void> {
|
||||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||||
|
await publishTurn;
|
||||||
try {
|
try {
|
||||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||||
this.publishUsageSources(pluginId);
|
this.publishUsageSources(pluginId);
|
||||||
|
|||||||
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
|||||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||||
const script = match[1].replace(/^ /gm, "");
|
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||||
const original = [
|
|
||||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
|
||||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
|
||||||
'["import", paths[i], "-P", password]',
|
|
||||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
|
||||||
"}",
|
|
||||||
].join("\n");
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
|
||||||
try {
|
try {
|
||||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
const desktop = join(directory, "packages/desktop");
|
||||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||||
const manifest = join(packageDirectory, "package.json");
|
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
writeFileSync(
|
||||||
for (const [version, source, succeeds] of [
|
join(desktop, "node_modules/electron-builder/package.json"),
|
||||||
["26.8.1", original, true],
|
JSON.stringify({
|
||||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
name: "electron-builder",
|
||||||
["26.18.0", original, false],
|
version: "1.0.0",
|
||||||
]) {
|
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
}),
|
||||||
writeFileSync(sourcePath, source);
|
);
|
||||||
const result = spawnSync(process.execPath, ["-e", script], {
|
writeFileSync(
|
||||||
cwd: directory,
|
join(directory, "package.json"),
|
||||||
encoding: "utf8",
|
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
|
||||||
});
|
);
|
||||||
const actual = readFileSync(sourcePath, "utf8");
|
writeFileSync(
|
||||||
if (succeeds) {
|
join(desktop, "package.json"),
|
||||||
assert.equal(result.status, 0, result.stderr);
|
JSON.stringify({
|
||||||
assert.ok(
|
name: "desktop-fixture",
|
||||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
private: true,
|
||||||
);
|
devDependencies: { "electron-builder": "1.0.0" },
|
||||||
assert.ok(
|
}),
|
||||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
);
|
||||||
);
|
writeFileSync(
|
||||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
join(desktop, "node_modules/.bin/electron-builder"),
|
||||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
|
||||||
} else {
|
{ mode: 0o755 },
|
||||||
assert.notEqual(result.status, 0);
|
);
|
||||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
|
||||||
}
|
cwd: desktop,
|
||||||
}
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user